modules = new HashMap<>();
+ private final Path filePath;
+
+ public static ToggleableConfig get() {
+ if (instance == null) {
+ instance = new ToggleableConfig(Services.PLATFORM.getConfigDir().resolve("modules.conf"));
+ }
+ return instance;
+ }
+
+ ToggleableConfig(Path filePath) {
+ this.filePath = filePath;
+ load();
+ }
+
+ public boolean isEnabled(String id) {
+ return modules.computeIfAbsent(id, (i) -> true);
+ }
+
+ private void load() {
+ if (!this.filePath.toFile().exists()) {
+ return;
+ }
+ try (var br = new BufferedReader(new FileReader(this.filePath.toFile()))) {
+ String line;
+ while ((line = br.readLine()) != null) {
+ var parts = line.split("=");
+ if (parts.length != 2) {
+ continue;
+ }
+
+ var key = parts[0].trim();
+ var value = parts[1].trim();
+
+ var enabled = Boolean.parseBoolean(value);
+ modules.put(key, enabled);
+ }
+ } catch (Exception e) {
+ System.out.println("Error loading toggleable state of modules. Assuming all enabled. " + e.getMessage());
+ }
+ }
+
+ public void save() {
+ var list = modules.entrySet().stream().map(e -> new Entry(e.getKey(), e.getValue())).sorted(Comparator.comparing(Entry::id)).toList();
+ try (var bw = new BufferedWriter(new FileWriter(this.filePath.toFile()))) {
+ for (var entry : list) {
+ bw.write(entry.id() + "=" + entry.enabled());
+ bw.newLine();
+ }
+ } catch (Exception e) {
+ System.out.println("Error saving toggleable state of modules. Assuming all enabled in the next load." + e.getMessage());
+ }
+ }
+
+ private record Entry(String id, boolean enabled) {
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/UserCache.java b/common/src/main/java/me/alexdevs/solstice/core/UserCache.java
new file mode 100644
index 0000000..c7d5cce
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/UserCache.java
@@ -0,0 +1,237 @@
+package me.alexdevs.solstice.core;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.io.Files;
+import com.google.gson.*;
+import com.mojang.authlib.GameProfile;
+import me.alexdevs.solstice.Constants;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Stream;
+
+/**
+ * The reason I made this instead of using Minecraft's UserCache is because:
+ *
+ * 1. I do not want to use the API to look up missing profiles, just return an empty value instead.
+ *
+ * 2. Using the API to look up profiles is slow and hangs the server, it's annoying.
+ *
+ * The source file is the original usercache.json and saving is disabled.
+ */
+public class UserCache {
+ private final Gson gson = new GsonBuilder()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
+ .disableHtmlEscaping()
+ .excludeFieldsWithoutExposeAnnotation()
+ .create();
+
+ private final Map byName = Maps.newConcurrentMap();
+ private final Map byUUID = Maps.newConcurrentMap();
+ private final AtomicLong accessCount = new AtomicLong();
+ private final File cacheFile;
+
+ public UserCache(File cacheFile) {
+ this.cacheFile = cacheFile;
+ Lists.reverse(this.load()).forEach(this::add);
+ }
+
+ private long incrementAndGetAccessCount() {
+ return this.accessCount.incrementAndGet();
+ }
+
+ public Optional getByName(String name) {
+ name = name.toLowerCase(Locale.ROOT);
+ var entry = byName.get(name);
+
+ if (entry == null) {
+ return Optional.empty();
+ } else {
+ return Optional.of(entry.getProfile());
+ }
+ }
+
+ public Optional getByUUID(UUID uuid) {
+ var entry = byUUID.get(uuid);
+ if (entry == null) {
+ return Optional.empty();
+ } else {
+ return Optional.of(entry.getProfile());
+ }
+ }
+
+ public List getAllNames() {
+ return ImmutableList.copyOf(this.byName.keySet());
+ }
+
+ public void add(GameProfile profile) {
+ var calendar = Calendar.getInstance();
+ calendar.setTime(new Date());
+ calendar.add(Calendar.MONTH, 1);
+ var date = calendar.getTime();
+ var entry = new Entry(profile, date);
+ this.add(entry);
+ //this.save();
+ }
+
+ private void add(Entry entry) {
+ var gameProfile = entry.getProfile();
+ entry.setLastAccessed(this.incrementAndGetAccessCount());
+ var name = gameProfile.getName();
+ if (name != null) {
+ this.byName.put(name.toLowerCase(Locale.ROOT), entry);
+ }
+
+ var uuid = gameProfile.getId();
+ if (uuid != null) {
+ byUUID.put(uuid, entry);
+ }
+ }
+
+ private static JsonElement entryToJson(Entry entry, DateFormat dateFormat) {
+ JsonObject jsonObject = new JsonObject();
+ jsonObject.addProperty("name", entry.getProfile().getName());
+ UUID uUID = entry.getProfile().getId();
+ jsonObject.addProperty("uuid", uUID == null ? "" : uUID.toString());
+ jsonObject.addProperty("expiresOn", dateFormat.format(entry.getExpirationDate()));
+ return jsonObject;
+ }
+
+ private static Optional entryFromJson(JsonElement json, DateFormat dateFormat) {
+ if (!json.isJsonObject())
+ return Optional.empty();
+
+ var root = json.getAsJsonObject();
+ var nameJson = root.get("name");
+ var uuidJson = root.get("uuid");
+ var expiresJson = root.get("expiresOn");
+ if (nameJson == null || uuidJson == null) {
+ return Optional.empty();
+ }
+
+ var uuid = uuidJson.getAsString();
+ var name = nameJson.getAsString();
+ Date date = null;
+ if (expiresJson != null) {
+ try {
+ date = dateFormat.parse(expiresJson.getAsString());
+ } catch (ParseException e) {
+ }
+ }
+
+ if (name != null && uuid != null && date != null) {
+ UUID uUID;
+ try {
+ uUID = UUID.fromString(uuid);
+ } catch (Throwable e) {
+ return Optional.empty();
+ }
+
+ return Optional.of(new Entry(new GameProfile(uUID, name), date));
+ }
+ return Optional.empty();
+ }
+
+ public List load() {
+ var list = new ArrayList();
+
+ try {
+ var reader = Files.newReader(this.cacheFile, StandardCharsets.UTF_8);
+
+ try {
+ var array = gson.fromJson(reader, JsonArray.class);
+ if (array == null)
+ return list;
+
+ var dateFormat = getDateFormat();
+ array.forEach(json -> entryFromJson(json, dateFormat).ifPresent(list::add));
+ } catch (Exception e) {
+ try {
+ reader.close();
+ } catch (Throwable ee) {
+ e.addSuppressed(ee);
+ }
+ }
+
+ if (reader != null)
+ reader.close();
+
+ return list;
+ } catch (FileNotFoundException e) {
+ // Do nothing
+ } catch (JsonParseException | IOException e) {
+ Constants.LOG.warn("Failed to load Solstice profile cache {}", this.cacheFile, e);
+ }
+
+ return list;
+ }
+
+ private void save() {
+ var jsonArray = new JsonArray();
+ var dateFormat = getDateFormat();
+ this.getLastAccessedEntries(1000).forEach(entry -> jsonArray.add(entryToJson(entry, dateFormat)));
+ var json = this.gson.toJson(jsonArray);
+
+ try {
+ var writer = Files.newWriter(this.cacheFile, StandardCharsets.UTF_8);
+ try {
+ writer.write(json);
+ } catch (IOException e) {
+ try {
+ writer.close();
+ } catch (IOException ee) {
+ e.addSuppressed(ee);
+ }
+ throw e;
+ }
+ writer.close();
+ } catch (IOException e) {
+ }
+ }
+
+ private Stream getLastAccessedEntries(int limit) {
+ return ImmutableList.copyOf(this.byUUID.values()).stream()
+ .sorted(Comparator.comparing(Entry::getLastAccessed).reversed())
+ .limit(limit);
+ }
+
+ private static DateFormat getDateFormat() {
+ return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.ROOT);
+ }
+
+ public static class Entry {
+ private final GameProfile profile;
+ final Date expirationDate;
+ private volatile long lastAccessed;
+
+ Entry(GameProfile profile, Date expirationDate) {
+ this.profile = profile;
+ this.expirationDate = expirationDate;
+ }
+
+ public GameProfile getProfile() {
+ return this.profile;
+ }
+
+ public Date getExpirationDate() {
+ return this.expirationDate;
+ }
+
+ public void setLastAccessed(long lastAccessed) {
+ this.lastAccessed = lastAccessed;
+ }
+
+ public long getLastAccessed() {
+ return this.lastAccessed;
+ }
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/WarmUpManager.java b/common/src/main/java/me/alexdevs/solstice/core/WarmUpManager.java
new file mode 100644
index 0000000..ed8e174
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/WarmUpManager.java
@@ -0,0 +1,7 @@
+package me.alexdevs.solstice.core;
+
+public class WarmUpManager {
+ public WarmUpManager() {
+
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/coreModule/CoreModule.java b/common/src/main/java/me/alexdevs/solstice/core/coreModule/CoreModule.java
new file mode 100644
index 0000000..20588a7
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/coreModule/CoreModule.java
@@ -0,0 +1,89 @@
+package me.alexdevs.solstice.core.coreModule;
+
+import me.alexdevs.solstice.Solstice;
+import me.alexdevs.solstice.api.ServerLocation;
+import me.alexdevs.solstice.api.events.SolsticeEvents;
+import me.alexdevs.solstice.api.events.WorldSaveCallback;
+import me.alexdevs.solstice.api.module.ModuleBase;
+import me.alexdevs.solstice.core.coreModule.commands.PingCommand;
+import me.alexdevs.solstice.core.coreModule.commands.ServerStatCommand;
+import me.alexdevs.solstice.core.coreModule.commands.SolsticeCommand;
+import me.alexdevs.solstice.core.coreModule.data.CoreConfig;
+import me.alexdevs.solstice.core.coreModule.data.CoreLocale;
+import me.alexdevs.solstice.core.coreModule.data.CorePlayerData;
+import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
+import net.minecraft.world.entity.Entity;
+import java.util.Date;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+public class CoreModule extends ModuleBase {
+ public static final String ID = "core";
+
+ public CoreModule() {
+ super(ID);
+ }
+
+ @Override
+ public void init() {
+ Solstice.configManager.registerData(ID, CoreConfig.class, CoreConfig::new);
+ Solstice.localeManager.registerShared(CoreLocale.SHARED);
+ Solstice.localeManager.registerModule(ID, CoreLocale.MODULE);
+
+ Solstice.playerData.registerData(ID, CorePlayerData.class, CorePlayerData::new);
+
+ commands.add(new SolsticeCommand(this));
+ commands.add(new ServerStatCommand(this));
+ commands.add(new PingCommand(this));
+
+ ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
+ Solstice.getUserCache().add(handler.getPlayer().getGameProfile());
+ var player = handler.getPlayer();
+ var playerData = Solstice.playerData.get(player).getData(CorePlayerData.class);
+ playerData.username = player.getGameProfile().getName();
+ playerData.lastSeenDate = new Date();
+ playerData.ipAddress = handler.getPlayer().getIpAddress();
+
+ if (playerData.firstJoinedDate == null) {
+ Solstice.LOGGER.info("Player {} joined for the first time!", player.getGameProfile().getName());
+ playerData.firstJoinedDate = new Date();
+ SolsticeEvents.WELCOME.invoker().onWelcome(player, server);
+ }
+
+ if (playerData.username != null && !playerData.username.equals(player.getGameProfile().getName())) {
+ Solstice.LOGGER.info("Player {} has changed their username from {}", player.getGameProfile().getName(), playerData.username);
+ SolsticeEvents.USERNAME_CHANGE.invoker().onUsernameChange(player, playerData.username);
+ }
+ });
+
+ ServerPlayConnectionEvents.DISCONNECT.register((handler, client) -> {
+ var playerData = Solstice.playerData.get(handler.getPlayer()).getData(CorePlayerData.class);
+ playerData.lastSeenDate = new Date();
+ playerData.logoffPosition = new ServerLocation(handler.getPlayer());
+ Solstice.scheduler.schedule(() -> {
+ Solstice.playerData.dispose(handler.getPlayer().getUUID());
+ }, 1, TimeUnit.SECONDS);
+ });
+
+ WorldSaveCallback.EVENT.register((server, suppressLogs, flush, force) -> {
+ var uuids = server.getPlayerList().getPlayers().stream().map(Entity::getUUID).toList();
+ Solstice.playerData.disposeMissing(uuids);
+ });
+ }
+
+ public static CoreConfig getConfig() {
+ return Solstice.configManager.getData(CoreConfig.class);
+ }
+
+ public static CorePlayerData getPlayerData(UUID uuid) {
+ return Solstice.playerData.get(uuid).getData(CorePlayerData.class);
+ }
+
+ public static String getUsername(UUID uuid) {
+ var profile = Solstice.server.getProfileCache().get(uuid);
+ if(profile.isPresent())
+ return profile.get().getName();
+
+ return uuid.toString();
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/PingCommand.java b/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/PingCommand.java
new file mode 100644
index 0000000..8eec6e4
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/PingCommand.java
@@ -0,0 +1,50 @@
+package me.alexdevs.solstice.core.coreModule.commands;
+
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import me.alexdevs.solstice.api.module.ModCommand;
+import me.alexdevs.solstice.core.coreModule.CoreModule;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.commands.arguments.EntityArgument;
+import net.minecraft.network.chat.Component;
+import java.util.List;
+import java.util.Map;
+
+public class PingCommand extends ModCommand {
+ public PingCommand(CoreModule module) {
+ super(module);
+ }
+
+ @Override
+ public List getNames() {
+ return List.of("ping");
+ }
+
+ @Override
+ public LiteralArgumentBuilder command(String name) {
+ return Commands.literal(name)
+ .requires(require("ping.base", true))
+ .executes(context -> {
+ var player = context.getSource().getPlayerOrException();
+ var ping = player.connection.latency();
+ var map = Map.of(
+ "ping", Component.nullToEmpty(String.valueOf(ping))
+ );
+ context.getSource().sendSuccess(() -> module.locale().get("ping.self", map), false);
+ return 1;
+ })
+ .then(Commands.argument("player", EntityArgument.player())
+ .requires(require("ping.others", 1))
+ .executes(context -> {
+ var player = EntityArgument.getPlayer(context, "player");
+ var ping = player.connection.latency();
+ var map = Map.of(
+ "ping", Component.nullToEmpty(String.valueOf(ping)),
+ "player", player.getName()
+ );
+ context.getSource().sendSuccess(() -> module.locale().get("ping.other", map), false);
+ return 1;
+ })
+ );
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/ServerStatCommand.java b/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/ServerStatCommand.java
new file mode 100644
index 0000000..0305d72
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/ServerStatCommand.java
@@ -0,0 +1,78 @@
+package me.alexdevs.solstice.core.coreModule.commands;
+
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import eu.pb4.placeholders.api.PlaceholderContext;
+import me.alexdevs.solstice.api.command.TimeSpan;
+import me.alexdevs.solstice.api.module.ModCommand;
+import me.alexdevs.solstice.core.coreModule.CoreModule;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+import net.minecraft.network.chat.Component;
+import java.lang.management.ManagementFactory;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class ServerStatCommand extends ModCommand {
+ public ServerStatCommand(CoreModule module) {
+ super(module);
+ }
+
+ @Override
+ public List getNames() {
+ return List.of("serverstat", "tps");
+ }
+
+ @Override
+ public LiteralArgumentBuilder command(String name) {
+ return Commands.literal(name)
+ .requires(require("serverstat", 3))
+ .executes(context -> {
+ var locale = module.locale();
+ var placeholderContext = PlaceholderContext.of(context.getSource());
+
+ var messages = new ArrayList();
+
+ messages.add(locale.get("stat.tps", placeholderContext));
+
+ var uptime = Duration.ofMillis(ManagementFactory.getRuntimeMXBean().getUptime());
+ var uptimeFormatted = TimeSpan.toShortString((int)uptime.getSeconds());
+ messages.add(locale.get("stat.uptime", placeholderContext, Map.of(
+ "uptime", Component.nullToEmpty(uptimeFormatted)
+ )));
+
+ var maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
+ var allocatedMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
+ var freeMemory = Runtime.getRuntime().freeMemory() / 1024 / 1024;
+
+ messages.add(locale.get("stat.maxMemory", placeholderContext, Map.of(
+ "memory", Component.nullToEmpty(String.valueOf(maxMemory)),
+ "hover", locale.get("stat.maxMemory.hover")
+ )));
+
+ messages.add(locale.get("stat.dedicatedMemory", placeholderContext, Map.of(
+ "memory", Component.nullToEmpty(String.valueOf(allocatedMemory)),
+ "hover", locale.get("stat.dedicatedMemory.hover")
+
+ )));
+
+ messages.add(locale.get("stat.freeMemory", placeholderContext, Map.of(
+ "memory", Component.nullToEmpty(String.valueOf(freeMemory)),
+ "hover", locale.get("stat.freeMemory.hover")
+ )));
+
+ var text = Component.empty();
+ text.append(locale.get("stat.title"));
+
+ for(var message : messages) {
+ text.append(Component.nullToEmpty("\n"));
+ text.append(message);
+ }
+
+ context.getSource().sendSuccess(() -> text, false);
+
+ return 1;
+ });
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/SolsticeCommand.java b/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/SolsticeCommand.java
new file mode 100644
index 0000000..e351465
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/coreModule/commands/SolsticeCommand.java
@@ -0,0 +1,138 @@
+package me.alexdevs.solstice.core.coreModule.commands;
+
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
+import me.alexdevs.solstice.Solstice;
+import me.alexdevs.solstice.api.events.SolsticeEvents;
+import me.alexdevs.solstice.api.module.Debug;
+import me.alexdevs.solstice.api.module.ModCommand;
+import me.alexdevs.solstice.core.coreModule.CoreModule;
+import me.alexdevs.solstice.api.text.Format;
+import net.fabricmc.loader.api.FabricLoader;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.network.chat.ClickEvent;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.HoverEvent;
+import net.minecraft.network.chat.Style;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+
+import static net.minecraft.commands.Commands.literal;
+
+public class SolsticeCommand extends ModCommand {
+ public SolsticeCommand(CoreModule module) {
+ super(module);
+ }
+
+ @Override
+ public List getNames() {
+ return List.of("solstice", "sol");
+ }
+
+ @Override
+ public LiteralArgumentBuilder command(String name) {
+ return literal(name)
+ .requires(require(true))
+ .executes(context -> {
+ var modContainer = FabricLoader.getInstance().getModContainer(Solstice.MOD_ID).orElse(null);
+ if (modContainer == null) {
+ context.getSource().sendSuccess(() -> Component.nullToEmpty("Could not find self in mod list???"), false);
+ return 1;
+ }
+
+ var metadata = modContainer.getMetadata();
+ var placeholders = Map.of(
+ "name", Component.nullToEmpty(metadata.getName()),
+ "version", Component.nullToEmpty(metadata.getVersion().getFriendlyString())
+ );
+
+ var text = Format.parse(
+ "${name} v${version}",
+ placeholders);
+ context.getSource().sendSuccess(() -> text, false);
+
+ return 1;
+ })
+ .then(literal("reload")
+ .requires(require("reload", 3))
+ .executes(context -> {
+ try {
+ Solstice.configManager.loadData(true);
+ Solstice.localeManager.reload();
+ } catch (Exception e) {
+ Solstice.LOGGER.error("Failed to reload Solstice", e);
+ context.getSource().sendSuccess(() -> Component.nullToEmpty("Failed to load Solstice config. Check console for more info."), true);
+ return 1;
+ }
+
+ SolsticeEvents.RELOAD.invoker().onReload(Solstice.getInstance());
+
+ context.getSource().sendSuccess(() -> Component.nullToEmpty("Reloaded Solstice config"), true);
+
+ return 1;
+ }))
+ .then(literal("debug")
+ .requires(require("debug", 4))
+ .then(literal("gen-command-list")
+ .executes(context -> {
+ var builder = new StringBuilder();
+
+ var list = new ArrayList<>(Debug.commandDebugList);
+
+ list.sort(Comparator.comparing(Debug.CommandDebug::module));
+
+ builder.append(String.format("| %s | %s | %s | %s |\n", "Module", "Command", "Aliases", "Permission"));
+ builder.append("|---|---|---|---|\n");
+ for (var command : list) {
+ builder.append(String.format("| %s | %s | %s | %s |\n", command.module(), command.command(), String.join(" ", command.commands()), command.permission()));
+ }
+
+ var output = builder.toString();
+
+ var file = FabricLoader.getInstance().getGameDir().resolve("solstice-commands.md").toFile();
+ try (var fw = new FileWriter(file)) {
+ fw.write(output);
+ } catch (IOException e) {
+ throw new SimpleCommandExceptionType(Component.nullToEmpty(e.getMessage())).create();
+ }
+
+ context.getSource().sendSuccess(() -> Component.nullToEmpty("Generated 'solstice-commands.md'"), true);
+
+ return 1;
+ }))
+ .then(literal("tags")
+ .executes(context -> {
+ var player = context.getSource().getPlayerOrException();
+
+ var hand = player.getUsedItemHand();
+ var itemStack = player.getItemInHand(hand);
+
+ var entry = itemStack.getItemHolder().unwrapKey().get();
+ var entryString = String.format("Tags for [%s / %s]:", entry.registry(), entry.location());
+
+ var text = Component.empty();
+ text.append(Component.nullToEmpty(entryString));
+ var tags = itemStack.getTags().iterator();
+ while(tags.hasNext()) {
+ var tag = tags.next();
+ text.append(Component.nullToEmpty("\n"));
+ text.append(
+ Component.literal(" #" + tag.location())
+ .setStyle(Style.EMPTY
+ .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.nullToEmpty("Click to copy")))
+ .withClickEvent(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, "#" + tag.location()))
+ )
+ );
+ }
+
+ context.getSource().sendSuccess(() -> text, false);
+
+ return 1;
+ }))
+ );
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CoreConfig.java b/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CoreConfig.java
new file mode 100644
index 0000000..1f866dd
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CoreConfig.java
@@ -0,0 +1,22 @@
+package me.alexdevs.solstice.core.coreModule.data;
+
+import org.spongepowered.configurate.objectmapping.ConfigSerializable;
+import org.spongepowered.configurate.objectmapping.meta.Comment;
+
+@ConfigSerializable
+public class CoreConfig {
+ @Comment("Generic date format to use.\nMetric format: dd/MM/yyyy\nUSA format: MM/dd/yyyy")
+ public String dateFormat = "dd/MM/yyyy";
+
+ @Comment("Generic time format to use.\n24h format: HH:mm\n12h format: hh:mm a")
+ public String timeFormat = "HH:mm";
+
+ @Comment("Generic date + time format to use.")
+ public String dateTimeFormat = "dd/MM/yyyy HH:mm";
+
+ @Comment("Format to use when displaying links in chat.")
+ public String link = "${label}";
+
+ @Comment("Format to use when hovering over the link in chat.")
+ public String linkHover = "${url}";
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CoreLocale.java b/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CoreLocale.java
new file mode 100644
index 0000000..8ca1117
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CoreLocale.java
@@ -0,0 +1,44 @@
+package me.alexdevs.solstice.core.coreModule.data;
+
+import java.util.Map;
+
+public class CoreLocale {
+ public static final Map SHARED = Map.ofEntries(
+ Map.entry("button", "[${label}]"),
+ Map.entry("buttonSuggest", "[${label}]"),
+ Map.entry("accept", "Accept"),
+ Map.entry("refuse", "Refuse"),
+ Map.entry("accept.hover", "Click to accept"),
+ Map.entry("refuse.hover", "Click to refuse"),
+ Map.entry("tooManyTargets", "The provided selector contains too many targets."),
+ Map.entry("cooldown", "You are on cooldown for ${timespan}."),
+ Map.entry("unit.second", "${n} second"),
+ Map.entry("unit.seconds", "${n} seconds"),
+ Map.entry("unit.minute", "${n} minute"),
+ Map.entry("unit.minutes", "${n} minutes"),
+ Map.entry("unit.hour", "${n} hour"),
+ Map.entry("unit.hours", "${n} hours"),
+ Map.entry("unit.day", "${n} day"),
+ Map.entry("unit.days", "${n} days"),
+ Map.entry("unit.week", "${n} week"),
+ Map.entry("unit.weeks", "${n} weeks"),
+ Map.entry("unit.month", "${n} month"),
+ Map.entry("unit.months", "${n} months"),
+ Map.entry("unit.year", "${n} year"),
+ Map.entry("unit.years", "${n} years")
+ );
+
+ public static final Map MODULE = Map.ofEntries(
+ Map.entry("stat.title", "Server Statistics"),
+ Map.entry("stat.tps", "Current TPS: %server:tps_colored%/20.0"),
+ Map.entry("stat.uptime", "Server Uptime: ${uptime}"),
+ Map.entry("stat.maxMemory", "Maximum memory: ${memory} MB"),
+ Map.entry("stat.maxMemory.hover", "How much memory the JVM can take at most in the system."),
+ Map.entry("stat.dedicatedMemory", "Dedicated memory: ${memory} MB"),
+ Map.entry("stat.dedicatedMemory.hover", "How much memory the JVM is using, can expand up to maximum memory."),
+ Map.entry("stat.freeMemory", "Free memory: ${memory} MB"),
+ Map.entry("stat.freeMemory.hover", "How much memory is left free in the dedicated memory."),
+ Map.entry("ping.self", "Ping: ${ping}ms"),
+ Map.entry("ping.other", "${player}'s ping: ${ping}ms")
+ );
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CorePlayerData.java b/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CorePlayerData.java
new file mode 100644
index 0000000..b4b55ce
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/core/coreModule/data/CorePlayerData.java
@@ -0,0 +1,14 @@
+package me.alexdevs.solstice.core.coreModule.data;
+
+import me.alexdevs.solstice.api.ServerLocation;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Date;
+
+public class CorePlayerData {
+ public String username;
+ public @Nullable Date firstJoinedDate;
+ public @Nullable Date lastSeenDate;
+ public @Nullable String ipAddress;
+ public @Nullable ServerLocation logoffPosition = null;
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/data/PlayerData.java b/common/src/main/java/me/alexdevs/solstice/data/PlayerData.java
new file mode 100644
index 0000000..5764a93
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/data/PlayerData.java
@@ -0,0 +1,137 @@
+package me.alexdevs.solstice.data;
+
+import com.google.gson.*;
+import me.alexdevs.solstice.Constants;
+import net.minecraft.Util;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+public class PlayerData {
+ protected final UUID uuid;
+ protected final Path filePath;
+ protected final Path basePath;
+
+ protected final Map> classMap = new HashMap<>();
+ protected final Map, Object> data = new HashMap<>();
+ protected final Map, Supplier>> providers = new HashMap<>();
+ protected final Gson gson = new GsonBuilder()
+ .setPrettyPrinting()
+ .disableHtmlEscaping()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX")
+ .serializeNulls()
+ .create();
+ protected JsonObject node;
+
+ public PlayerData(Path basePath, UUID uuid, Map> classMap, Map, Supplier>> providers) {
+ this.uuid = uuid;
+ this.classMap.putAll(classMap);
+ this.providers.putAll(providers);
+ this.basePath = basePath;
+ this.filePath = basePath.resolve(uuid + ".json");
+
+ loadData(false);
+ }
+
+ public Path getDataPath() {
+ return this.filePath;
+ }
+
+ @SuppressWarnings("unchecked")
+ public T getData(Class clazz) {
+ if (this.data.containsKey(clazz))
+ return (T) this.data.get(clazz);
+
+ if (this.providers.containsKey(clazz)) {
+ final T result = (T) this.providers.get(clazz).get();
+ this.data.put(clazz, result);
+ return result;
+ }
+
+ throw new IllegalArgumentException(clazz.getSimpleName() + " does not exist");
+ }
+
+ public void save() {
+ for (var entry : classMap.entrySet()) {
+ var obj = data.get(entry.getValue());
+ node.add(entry.getKey(), gson.toJsonTree(obj));
+ }
+
+ var parentDir = filePath.getParent();
+ var fileName = filePath.getFileName().toString();
+
+ if (parentDir.toFile().mkdirs()) {
+ Constants.LOG.debug("Players data directory created.");
+ }
+
+ try {
+ var temp = File.createTempFile(uuid.toString() + "-", ".json", parentDir.toFile());
+ var tempWriter = new FileWriter(temp);
+ gson.toJson(node, tempWriter);
+ tempWriter.close();
+
+ var target = filePath;
+ var backup = parentDir.resolve(fileName + "_old");
+ Util.safeReplaceFile(target, temp.toPath(), backup);
+ } catch (Exception e) {
+ Constants.LOG.error("Could not save {}. This will lead to data loss!", filePath, e);
+ }
+ }
+
+ public void registerData(String id, Class clazz, Supplier creator) {
+ classMap.put(id, clazz);
+ providers.put(clazz, creator);
+ }
+
+ public void loadData(boolean force) {
+ if (node == null || force) {
+ node = loadNode();
+ }
+ data.clear();
+
+ for (var entry : classMap.entrySet()) {
+ data.put(entry.getValue(), get(node.get(entry.getKey()), entry.getValue()));
+ }
+ }
+
+ protected JsonObject loadNode() {
+ if (!this.filePath.toFile().exists())
+ return new JsonObject();
+ try (var fr = new FileReader(this.filePath.toFile())) {
+ var reader = gson.newJsonReader(fr);
+ return JsonParser.parseReader(reader).getAsJsonObject();
+ } catch (IOException e) {
+ Constants.LOG.error("Could not load player data of UUID {}!", uuid, e);
+ safeMove();
+ return new JsonObject();
+ }
+ }
+
+ protected void safeMove() {
+ var df = new SimpleDateFormat("yyyyMMddHHmmss");
+ var date = df.format(new Date());
+ var newPath = basePath.resolve(String.format("%s.%s.json", uuid, date));
+ if (filePath.toFile().renameTo(newPath.toFile())) {
+ Constants.LOG.warn("{} has been renamed to {}!", filePath, newPath);
+ } else {
+ Constants.LOG.error("Could not move file {}. Solstice cannot safely manage player data.", filePath);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ protected T get(@Nullable JsonElement node, Class clazz) {
+ if (node == null)
+ return (T) providers.get(clazz).get();
+ return gson.fromJson(node, clazz);
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/data/PlayerDataManager.java b/common/src/main/java/me/alexdevs/solstice/data/PlayerDataManager.java
new file mode 100644
index 0000000..65e2a93
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/data/PlayerDataManager.java
@@ -0,0 +1,115 @@
+package me.alexdevs.solstice.data;
+
+import com.mojang.authlib.GameProfile;
+import me.alexdevs.solstice.Constants;
+import net.minecraft.server.level.ServerPlayer;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+public class PlayerDataManager {
+ private final Map> classMap = new HashMap<>();
+ private final Map, Supplier>> providers = new HashMap<>();
+ private final Map playerData = new ConcurrentHashMap<>();
+ private Path basePath;
+
+ public Path getDataPath() {
+ return basePath;
+ }
+
+ public void setDataPath(Path basePath) {
+ this.basePath = basePath;
+ }
+
+ /**
+ * Register data model for the player
+ *
+ * @param id Module key in the data
+ * @param clazz Class of data
+ * @param creator Default values provider
+ * @param Type of class of data
+ */
+ public void registerData(String id, Class clazz, Supplier creator) {
+ classMap.put(id, clazz);
+ providers.put(clazz, creator);
+ }
+
+ /**
+ * Get data of a player. Will load if not loaded.
+ *
+ * @param uuid Player UUID
+ * @return player data
+ */
+ public PlayerData get(UUID uuid) {
+ if (!playerData.containsKey(uuid)) {
+ return load(uuid);
+ }
+ return playerData.get(uuid);
+ }
+
+ /**
+ * Get data of a player. Will load if not loaded.
+ *
+ * @param player Player
+ * @return player data
+ */
+ public PlayerData get(ServerPlayer player) {
+ return get(player.getUUID());
+ }
+
+ /**
+ * Get data of a player. Will load if not loaded.
+ *
+ * @param profile Player profile
+ * @return player data
+ */
+ public PlayerData get(GameProfile profile) {
+ return get(profile.getId());
+ }
+
+ /**
+ * Save player data and unload from memory
+ *
+ * @param uuid Player UUID
+ */
+ public void dispose(UUID uuid) {
+ if (playerData.containsKey(uuid)) {
+ Constants.LOG.debug("Unloading player data {}", uuid);
+ var data = playerData.remove(uuid);
+ data.save();
+ }
+ }
+
+ public void disposeMissing(List uuids) {
+ for(var entry : playerData.entrySet()) {
+ if(!uuids.contains(entry.getKey())) {
+ dispose(entry.getKey());
+ }
+ }
+ }
+
+ private PlayerData load(UUID uuid) {
+ Constants.LOG.debug("Loading player data {}", uuid);
+ var data = new PlayerData(this.basePath, uuid, classMap, providers);
+ playerData.put(uuid, data);
+ return data;
+ }
+
+ /**
+ * Save all player data without disposing.
+ */
+ public void saveAll() {
+ if (!this.basePath.toFile().exists()) {
+ this.basePath.toFile().mkdirs();
+ }
+ for (var entry : playerData.entrySet()) {
+ var data = entry.getValue();
+ data.save();
+ }
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/data/ServerData.java b/common/src/main/java/me/alexdevs/solstice/data/ServerData.java
new file mode 100644
index 0000000..30d8208
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/data/ServerData.java
@@ -0,0 +1,124 @@
+package me.alexdevs.solstice.data;
+
+import com.google.gson.*;
+import me.alexdevs.solstice.Constants;
+import net.minecraft.Util;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+
+public class ServerData {
+ protected final Map> classMap = new HashMap<>();
+ protected final Map, Object> data = new HashMap<>();
+ protected final Map, Supplier>> providers = new HashMap<>();
+ protected final Gson gson = new GsonBuilder()
+ .setPrettyPrinting()
+ .disableHtmlEscaping()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX")
+ .serializeNulls()
+ .create();
+ protected Path filePath;
+ protected JsonObject node;
+
+ public Path getDataPath() {
+ return this.filePath;
+ }
+
+ public void setDataPath(Path filePath) {
+ this.filePath = filePath;
+ }
+
+ @SuppressWarnings("unchecked")
+ public T getData(Class clazz) {
+ if (this.data.containsKey(clazz))
+ return (T) this.data.get(clazz);
+
+ if (this.providers.containsKey(clazz)) {
+ final T result = (T) this.providers.get(clazz).get();
+ this.data.put(clazz, result);
+ return result;
+ }
+
+ throw new IllegalArgumentException(clazz.getSimpleName() + " does not exist");
+ }
+
+ public void save() {
+ for (var entry : classMap.entrySet()) {
+ var obj = data.get(entry.getValue());
+ node.add(entry.getKey(), gson.toJsonTree(obj));
+ }
+
+ var parentDir = filePath.getParent();
+ var fileName = filePath.getFileName().toString();
+
+ try {
+ var temp = File.createTempFile("server-", ".json", parentDir.toFile());
+ var tempWriter = new FileWriter(temp);
+ gson.toJson(node, tempWriter);
+ tempWriter.close();
+
+ var target = filePath;
+ var backup = parentDir.resolve(fileName + "_old");
+ Util.safeReplaceFile(target, temp.toPath(), backup);
+ } catch (Exception e) {
+ Constants.LOG.error("Could not save {}. This will lead to data loss!", filePath, e);
+ }
+ }
+
+ public void registerData(String id, Class clazz, Supplier creator) {
+ classMap.put(id, clazz);
+ providers.put(clazz, creator);
+ }
+
+ public void loadData(boolean force) {
+ if (node == null || force) {
+ node = loadNode();
+ }
+ data.clear();
+
+ for (var entry : classMap.entrySet()) {
+ data.put(entry.getValue(), get(node.get(entry.getKey()), entry.getValue()));
+ }
+ }
+
+ protected JsonObject loadNode() {
+ if (!this.filePath.toFile().exists())
+ return new JsonObject();
+ try (var fr = new FileReader(this.filePath.toFile())) {
+ var reader = gson.newJsonReader(fr);
+ return JsonParser.parseReader(reader).getAsJsonObject();
+ } catch (IOException e) {
+ Constants.LOG.error("Could not load server data!", e);
+ safeMove();
+ return new JsonObject();
+ }
+ }
+
+ protected void safeMove() {
+ var df = new SimpleDateFormat("yyyyMMddHHmmss");
+ var date = df.format(new Date());
+ var basePath = filePath.getParent();
+ var newPath = basePath.resolve(String.format("server.%s.json", date));
+ if (filePath.toFile().renameTo(newPath.toFile())) {
+ Constants.LOG.warn("{} has been renamed to {}!", filePath, newPath);
+ } else {
+ Constants.LOG.error("Could not move file {}. Solstice cannot safely manage server data.", filePath);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ protected T get(@Nullable JsonElement node, Class clazz) {
+ if (node == null)
+ return (T) providers.get(clazz).get();
+ return gson.fromJson(node, clazz);
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/integrations/LuckPermsIntegration.java b/common/src/main/java/me/alexdevs/solstice/integrations/LuckPermsIntegration.java
new file mode 100644
index 0000000..cee3f40
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/integrations/LuckPermsIntegration.java
@@ -0,0 +1,99 @@
+package me.alexdevs.solstice.integrations;
+
+import me.alexdevs.solstice.Solstice;
+import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
+import net.fabricmc.loader.api.FabricLoader;
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.LuckPermsProvider;
+import net.luckperms.api.event.user.UserDataRecalculateEvent;
+import net.minecraft.server.level.ServerPlayer;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class LuckPermsIntegration {
+
+ private static LuckPerms luckPerms;
+ private static boolean available = false;
+
+ private static final Map> prefixMap = new ConcurrentHashMap<>();
+ private static final Map> suffixMap = new ConcurrentHashMap<>();
+
+ public static void register() {
+ if (!isAvailable()) {
+ Solstice.LOGGER.warn("LuckPerms not available! It is recommended to install LuckPerms to configure permissions and groups.");
+ return;
+ }
+
+ var container = FabricLoader.getInstance().getModContainer(Solstice.MOD_ID).get();
+
+ ServerLifecycleEvents.SERVER_STARTED.register(server -> {
+ luckPerms = LuckPermsProvider.get();
+ available = true;
+ var eventBus = luckPerms.getEventBus();
+
+ eventBus.subscribe(container, UserDataRecalculateEvent.class, Listeners::onDataRecalculate);
+ });
+ }
+
+ public static boolean isAvailable() {
+ return FabricLoader.getInstance().isModLoaded("luckperms");
+ }
+
+ public static @Nullable String getPrefix(ServerPlayer player) {
+ if (!available) {
+ return null;
+ }
+
+ return prefixMap.computeIfAbsent(player.getUUID(), uuid -> {
+ try {
+ var playerMeta = luckPerms.getPlayerAdapter(ServerPlayer.class).getMetaData(player);
+ return Optional.ofNullable(playerMeta.getPrefix());
+ } catch (IllegalStateException e) {
+ // Fake player may throw with IllegalStateException
+ return Optional.empty();
+ }
+ }).orElse(null);
+ }
+
+ public static @Nullable String getSuffix(ServerPlayer player) {
+ if (!available) {
+ return null;
+ }
+
+ return suffixMap.computeIfAbsent(player.getUUID(), uuid -> {
+ try {
+ var playerMeta = luckPerms.getPlayerAdapter(ServerPlayer.class).getMetaData(player);
+ return Optional.ofNullable(playerMeta.getSuffix());
+ } catch (IllegalStateException e) {
+ // Fake player may throw with IllegalStateException
+ return Optional.empty();
+ }
+ }).orElse(null);
+ }
+
+ public static boolean isInGroup(ServerPlayer player, String group) {
+ if (!available) {
+ return false;
+ }
+ try {
+ var user = luckPerms.getPlayerAdapter(ServerPlayer.class).getUser(player);
+ var inheritedGroups = user.getInheritedGroups(user.getQueryOptions());
+ return inheritedGroups.stream().anyMatch(g -> g.getName().equalsIgnoreCase(group));
+ } catch (IllegalStateException e) {
+ // Fake player may throw with IllegalStateException
+ return false;
+ }
+ }
+
+ public static class Listeners {
+ public static void onDataRecalculate(UserDataRecalculateEvent event) {
+ var uuid = event.getUser().getUniqueId();
+ prefixMap.remove(uuid);
+ suffixMap.remove(uuid);
+ }
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/integrations/TrinketsIntegration.java b/common/src/main/java/me/alexdevs/solstice/integrations/TrinketsIntegration.java
new file mode 100644
index 0000000..20130fc
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/integrations/TrinketsIntegration.java
@@ -0,0 +1,9 @@
+package me.alexdevs.solstice.integrations;
+
+import net.fabricmc.loader.api.FabricLoader;
+
+public class TrinketsIntegration {
+ public static boolean isAvailable() {
+ return FabricLoader.getInstance().isModLoaded("trinkets");
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/locale/Locale.java b/common/src/main/java/me/alexdevs/solstice/locale/Locale.java
new file mode 100644
index 0000000..d26e4be
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/locale/Locale.java
@@ -0,0 +1,52 @@
+package me.alexdevs.solstice.locale;
+
+//import eu.pb4.placeholders.api.PlaceholderContext;
+import me.alexdevs.solstice.api.text.Format;
+import net.minecraft.network.chat.Component;
+
+import java.util.Map;
+import java.util.function.Supplier;
+
+public class Locale {
+ public final String id;
+
+ private final Supplier localeSupplier;
+
+ public Locale(String id, Supplier localeSupplier) {
+ this.id = id;
+ this.localeSupplier = localeSupplier;
+ }
+
+ public String raw(String path) {
+ String fullPath;
+ if (path.startsWith("~")) {
+ fullPath = "shared." + path.substring(1);
+ } else if (path.startsWith("/")) {
+ fullPath = path.substring(1);
+ } else {
+ fullPath = "module." + this.id + "." + path;
+ }
+
+ return localeSupplier.get().get(fullPath);
+ }
+
+ public Component get(String path) {
+ var src = this.raw(path);
+ return Format.parse(src);
+ }
+
+ public Component get(String path, PlaceholderContext context) {
+ var src = this.raw(path);
+ return Format.parse(src, context);
+ }
+
+ public Component get(String path, Map placeholders) {
+ var src = this.raw(path);
+ return Format.parse(src, placeholders);
+ }
+
+ public Component get(String path, PlaceholderContext context, Map placeholders) {
+ var src = this.raw(path);
+ return Format.parse(src, context, placeholders);
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/locale/LocaleManager.java b/common/src/main/java/me/alexdevs/solstice/locale/LocaleManager.java
new file mode 100644
index 0000000..86c2d04
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/locale/LocaleManager.java
@@ -0,0 +1,216 @@
+package me.alexdevs.solstice.locale;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+import me.alexdevs.solstice.Constants;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+
+public class LocaleManager {
+ private static final Gson gson = new GsonBuilder()
+ .disableHtmlEscaping()
+ .setPrettyPrinting()
+ .setDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX")
+ .create();
+ private static final Pattern sharedRegex = Pattern.compile("^shared\\.(.+)$");
+ private static final Pattern moduleRegex = Pattern.compile("^module\\.(\\w+)\\.(.+)$");
+ private final Path path;
+ private final TypeToken> oldType = TypeToken.getParameterized(Map.class, String.class, String.class);
+ private final LocaleModel defaultMap = new LocaleModel();
+ private LocaleModel locale;
+
+
+ public LocaleManager(Path path) {
+ this.path = path;
+ }
+
+ public static @Nullable LocalePath getPath(String fullPath) {
+ var matcher = sharedRegex.matcher(fullPath);
+ if (matcher.find()) {
+ var key = matcher.group(1);
+ return new LocalePath(LocaleType.SHARED, key);
+ }
+
+ matcher = moduleRegex.matcher(fullPath);
+ if (matcher.find()) {
+ var moduleId = matcher.group(1);
+ var key = matcher.group(2);
+ return new LocalePath(LocaleType.MODULE, key, moduleId);
+ }
+
+ return null;
+ }
+
+ public Locale getLocale(String id) {
+ return new Locale(id, () -> locale);
+ }
+
+ public void registerModule(String id, Map defaults) {
+ this.defaultMap.modules.put(id, new ConcurrentHashMap<>(defaults));
+ }
+
+ public void registerShared(Map defaults) {
+ this.defaultMap.shared.putAll(defaults);
+ }
+
+ public void load() throws IOException {
+ if (!path.toFile().exists()) {
+ locale = new LocaleModel();
+ prepare();
+ return;
+ }
+ var bf = new BufferedReader(new FileReader(path.toFile(), StandardCharsets.UTF_8));
+ locale = gson.fromJson(bf, LocaleModel.class);
+ bf.close();
+
+ if (locale.shared.isEmpty() && locale.modules.isEmpty()) {
+ Constants.LOG.warn("Locale casting failure. Attempting migration...");
+ migrate();
+ }
+
+ prepare();
+ }
+
+ public void save() throws IOException {
+ var fw = new FileWriter(path.toFile(), StandardCharsets.UTF_8);
+ gson.toJson(locale, fw);
+ fw.close();
+ }
+
+ private void prepare() {
+ if (locale == null)
+ return;
+
+ defaultMap.shared.forEach((key, value) -> locale.shared.putIfAbsent(key, value));
+
+ //defaultMap.modules.forEach((id, map) -> locale.modules.putIfAbsent(id, new ConcurrentHashMap<>()));
+ for (var defaultMods : defaultMap.modules.entrySet()) {
+ var module = locale.modules.computeIfAbsent(defaultMods.getKey(), id -> new ConcurrentHashMap<>());
+ for (var modLocale : defaultMap.modules.get(defaultMods.getKey()).entrySet()) {
+ module.putIfAbsent(modLocale.getKey(), modLocale.getValue());
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void migrate() {
+ locale = new LocaleModel();
+ try {
+ var bf = new BufferedReader(new FileReader(path.toFile(), StandardCharsets.UTF_8));
+ var oldLocale = (Map) gson.fromJson(bf, oldType);
+
+ for (var entry : oldLocale.entrySet()) {
+ var path = getPath(entry.getKey());
+ if (path == null) {
+ Constants.LOG.warn("Invalid locale path: {}", entry.getKey());
+ continue;
+ }
+
+ if (path.type() == LocaleType.SHARED) {
+ locale.shared.put(path.key(), entry.getValue());
+ } else if (path.type() == LocaleType.MODULE) {
+ locale.modules
+ .computeIfAbsent(path.moduleId(), key -> new ConcurrentHashMap<>())
+ .put(path.key(), entry.getValue());
+ }
+ }
+
+ bf.close();
+
+ Constants.LOG.info("Successfully migrated locale!");
+ } catch (IOException | JsonSyntaxException e) {
+ Constants.LOG.error("Could not load locale", e);
+ }
+ }
+
+ public Map generateMap() {
+ var map = new HashMap();
+
+ for (var entry : defaultMap.shared.entrySet()) {
+ map.put("shared." + entry.getKey(), entry.getValue());
+ }
+
+ for (var modEntry : defaultMap.modules.entrySet()) {
+ for (var entry : modEntry.getValue().entrySet()) {
+ map.put("module." + modEntry.getKey() + "." + entry.getKey(), entry.getValue());
+ }
+ }
+
+ return map;
+ }
+
+ public void reload() throws IOException {
+ load();
+ save();
+ }
+
+ public enum LocaleType {
+ SHARED,
+ MODULE
+ }
+
+ public static final class LocalePath {
+ private final LocaleType type;
+ private final String key;
+ private final @Nullable String moduleId;
+
+ public LocalePath(LocaleType type, String key, @Nullable String moduleId) {
+ this.type = type;
+ this.key = key;
+ this.moduleId = moduleId;
+ }
+
+ public LocalePath(LocaleType type, String key) {
+ this(type, key, null);
+ }
+
+ public LocaleType type() {
+ return type;
+ }
+
+ public String key() {
+ return key;
+ }
+
+ public @Nullable String moduleId() {
+ return moduleId;
+ }
+
+ }
+
+ public static class LocaleModel {
+ public ConcurrentHashMap shared = new ConcurrentHashMap<>();
+ public ConcurrentHashMap> modules = new ConcurrentHashMap<>();
+
+ public String get(String fullPath) {
+ var path = getPath(fullPath);
+ if (path == null) {
+ return fullPath;
+ }
+
+ if (path.type() == LocaleType.SHARED) {
+ return shared.getOrDefault(path.key(), fullPath);
+ } else if (path.type() == LocaleType.MODULE) {
+ var module = modules.get(path.moduleId());
+ if (module == null) {
+ return fullPath;
+ }
+ return module.getOrDefault(path.key(), fullPath);
+ }
+
+ return fullPath;
+ }
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/mixin/MixinMinecraft.java b/common/src/main/java/me/alexdevs/solstice/mixin/MixinMinecraft.java
new file mode 100644
index 0000000..f65fac1
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/mixin/MixinMinecraft.java
@@ -0,0 +1,18 @@
+package me.alexdevs.solstice.mixin;
+
+import me.alexdevs.solstice.Constants;
+import net.minecraft.client.Minecraft;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(Minecraft.class)
+public class MixinMinecraft {
+
+ @Inject(at = @At("TAIL"), method = "")
+ private void init(CallbackInfo info) {
+ Constants.LOG.info("This line is printed by the Solstice common mixin!");
+ Constants.LOG.info("MC Version: {}", Minecraft.getInstance().getVersionType());
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/platform/Services.java b/common/src/main/java/me/alexdevs/solstice/platform/Services.java
new file mode 100644
index 0000000..fc78771
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/platform/Services.java
@@ -0,0 +1,29 @@
+package me.alexdevs.solstice.platform;
+
+import me.alexdevs.solstice.Constants;
+import me.alexdevs.solstice.platform.services.IEventHelper;
+import me.alexdevs.solstice.platform.services.IPlatformHelper;
+
+import java.util.ServiceLoader;
+
+// Service loaders are a built-in Java feature that allow us to locate implementations of an interface that vary from one
+// environment to another. In the context of MultiLoader we use this feature to access a mock API in the common code that
+// is swapped out for the platform specific implementation at runtime.
+public class Services {
+
+ // In this example we provide a platform helper which provides information about what platform the mod is running on.
+ // For example this can be used to check if the code is running on Forge vs Fabric, or to ask the modloader if another
+ // mod is loaded.
+ public static final IPlatformHelper PLATFORM = load(IPlatformHelper.class);
+ public static final IEventHelper EVENT = load(IEventHelper.class);
+
+ // This code is used to load a service for the current environment. Your implementation of the service must be defined
+ // manually by including a text file in META-INF/services named with the fully qualified class name of the service.
+ // Inside the file you should write the fully qualified class name of the implementation to load for the platform. For
+ // example our file on Forge points to ForgePlatformHelper while Fabric points to FabricPlatformHelper.
+ public static T load(Class clazz) {
+ final T loadedService = ServiceLoader.load(clazz).findFirst().orElseThrow(() -> new NullPointerException("Failed to load service for " + clazz.getName()));
+ Constants.LOG.debug("Loaded {} for service {}", loadedService, clazz);
+ return loadedService;
+ }
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/platform/event/EventFactory.java b/common/src/main/java/me/alexdevs/solstice/platform/event/EventFactory.java
new file mode 100644
index 0000000..302ec70
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/platform/event/EventFactory.java
@@ -0,0 +1,43 @@
+package me.alexdevs.solstice.platform.event;
+
+import com.google.common.collect.MapMaker;
+
+import java.lang.reflect.Array;
+import java.util.Collections;
+import java.util.Set;
+import java.util.function.Function;
+
+public class EventFactory {
+ private static final Set> EVENTS = Collections.newSetFromMap(new MapMaker().weakKeys().makeMap());;
+ public static Event create(Class super T> type, Function invokerFactory) {
+
+ }
+
+ public static void register() {
+
+ }
+
+ @FunctionalInterface
+ interface AllowSleep {
+ boolean canSleep(String name);
+ }
+
+ public class Event {
+ private final Function invokerFactory;
+ private final Object lock = new Object();
+ private T[] handlers;
+
+ @SuppressWarnings("unchecked")
+ Event(Class super T> type, Function invokerFactory) {
+ this.invokerFactory = invokerFactory;
+ this.handlers = (T[]) Array.newInstance(type, 0);
+ update();
+ }
+
+ void update() {
+ this.invoker = invokerFactory.apply(handlers);
+ }
+ }
+}
+
+
diff --git a/common/src/main/java/me/alexdevs/solstice/platform/services/IEventHelper.java b/common/src/main/java/me/alexdevs/solstice/platform/services/IEventHelper.java
new file mode 100644
index 0000000..ab17c78
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/platform/services/IEventHelper.java
@@ -0,0 +1,4 @@
+package me.alexdevs.solstice.platform.services;
+
+public interface IEventHelper {
+}
diff --git a/common/src/main/java/me/alexdevs/solstice/platform/services/IPlatformHelper.java b/common/src/main/java/me/alexdevs/solstice/platform/services/IPlatformHelper.java
new file mode 100644
index 0000000..a9ac1cc
--- /dev/null
+++ b/common/src/main/java/me/alexdevs/solstice/platform/services/IPlatformHelper.java
@@ -0,0 +1,77 @@
+package me.alexdevs.solstice.platform.services;
+
+import net.minecraft.client.Minecraft;
+
+import java.nio.file.Path;
+
+public interface IPlatformHelper {
+
+ enum Platform {
+ FABRIC("Fabric"),
+ NEOFORGE("NeoForge");
+
+ public final String prettyName;
+
+ Platform(String pretty) {
+ this.prettyName = pretty;
+ }
+ }
+
+ /**
+ * Gets the name of the current platform
+ *
+ * @return The name of the current platform.
+ */
+ Platform getPlatformName();
+
+ /**
+ * Checks if a mod with the given id is loaded.
+ *
+ * @param modId The mod to check if it is loaded.
+ * @return True if the mod is loaded, false otherwise.
+ */
+ boolean isModLoaded(String modId);
+
+ /**
+ * Check if the game is currently in a development environment.
+ *
+ * @return True if in a development environment, false otherwise.
+ */
+ boolean isDevelopmentEnvironment();
+
+ /**
+ * Gets the name of the environment type as a string.
+ *
+ * @return The name of the environment type.
+ */
+ default String getEnvironmentName() {
+ return isDevelopmentEnvironment() ? "development" : "production";
+ }
+
+ /**
+ * Gets the version of Minecraft.
+ *
+ * @return The version of Minecraft.
+ */
+ default String getMinecraftVersion() {
+ return Minecraft.getInstance().getVersionType();
+ }
+
+ /**
+ *
+ * @return
+ */
+ String getVersion();
+
+ /**
+ * Gets the version of the running loader.
+ * Uses Fabric API is Fabric.
+ *
+ * @return The version of the running loader.
+ */
+ String getLoaderVersion();
+
+ Path getConfigDir();
+
+ Path getGameDir();
+}
diff --git a/common/src/main/resources/info/formatting.txt b/common/src/main/resources/info/formatting.txt
new file mode 100644
index 0000000..ebbb280
--- /dev/null
+++ b/common/src/main/resources/info/formatting.txt
@@ -0,0 +1,24 @@
+This page is an example of the formatting and placeholders used in pages and command texts.
+
+Formatting:
+
+Colors:
+yellow, dark_blue, dark_purple, gold, red, aqua, gray, light_purple, white, dark_gray, green, dark_green, blue, dark_aqua, dark_green, black.
+
+Decorations:
+strikethrough, st, underline, underlined, u, italic, i, obfuscated, obf(obfuscated, obf), bold, b
+
+Fonts:
+default, uniform(uniform), alt(alt)
+
+Gradients:
+smooth white to black, hard white to black, rainbow
+
+For the complete documentation on text formatting check out this link.
+
+Placeholders:
+
+Player name: %player:name%
+Player display name: %player:displayname%
+
+For the complete documentation on placeholders check out this link.
\ No newline at end of file
diff --git a/common/src/main/resources/info/motd.txt b/common/src/main/resources/info/motd.txt
new file mode 100644
index 0000000..83676e7
--- /dev/null
+++ b/common/src/main/resources/info/motd.txt
@@ -0,0 +1,6 @@
+Welcome to the server, %player:displayname%!
+
+The world time is %world:time%.
+There are %server:online%/%server:max_players% online players.
+
+Make sure to read the /rules!
\ No newline at end of file
diff --git a/common/src/main/resources/info/rules.txt b/common/src/main/resources/info/rules.txt
new file mode 100644
index 0000000..6d4f660
--- /dev/null
+++ b/common/src/main/resources/info/rules.txt
@@ -0,0 +1,3 @@
+1. Respect players.
+2. Respect staff members.
+3. Enjoy your stay!
\ No newline at end of file
diff --git a/common/src/main/resources/pack.mcmeta b/common/src/main/resources/pack.mcmeta
new file mode 100644
index 0000000..41b63a1
--- /dev/null
+++ b/common/src/main/resources/pack.mcmeta
@@ -0,0 +1,6 @@
+{
+ "pack": {
+ "description": "${mod_name}",
+ "pack_format": 8
+ }
+}
diff --git a/common/src/main/resources/solstice.accesswidener b/common/src/main/resources/solstice.accesswidener
new file mode 100644
index 0000000..8c2bc47
--- /dev/null
+++ b/common/src/main/resources/solstice.accesswidener
@@ -0,0 +1,6 @@
+accessWidener v2 named
+# RTP
+accessible method net/minecraft/server/level/ServerChunkCache getVisibleChunkIfPresent (J)Lnet/minecraft/server/level/ChunkHolder;
+
+# Inventory see
+accessible field net/minecraft/server/MinecraftServer playerDataStorage Lnet/minecraft/world/level/storage/PlayerDataStorage;
\ No newline at end of file
diff --git a/common/src/main/resources/solstice.mixins.json b/common/src/main/resources/solstice.mixins.json
new file mode 100644
index 0000000..4b6e296
--- /dev/null
+++ b/common/src/main/resources/solstice.mixins.json
@@ -0,0 +1,15 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "me.alexdevs.solstice.mixin",
+ "refmap": "${mod_id}.refmap.json",
+ "compatibilityLevel": "JAVA_18",
+ "mixins": [],
+ "client": [
+ "MixinMinecraft"
+ ],
+ "server": [],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/fabric/build.gradle b/fabric/build.gradle
new file mode 100644
index 0000000..41a023c
--- /dev/null
+++ b/fabric/build.gradle
@@ -0,0 +1,50 @@
+plugins {
+ id 'multiloader-loader'
+ id 'fabric-loom'
+}
+dependencies {
+ minecraft "com.mojang:minecraft:${minecraft_version}"
+ mappings loom.layered {
+ officialMojangMappings()
+ parchment("org.parchmentmc.data:parchment-${parchment_minecraft}:${parchment_version}@zip")
+ }
+ modImplementation "net.fabricmc:fabric-loader:${fabric_loader_version}"
+ modImplementation "net.fabricmc.fabric-api:fabric-api:${fabric_version}"
+
+ modImplementation include("org.spongepowered:configurate-core:${project.configurate_version}")
+ modImplementation include("org.spongepowered:configurate-hocon:${project.configurate_version}")
+ modImplementation include("org.spongepowered:configurate-gson:${project.configurate_version}")
+ include("com.typesafe:config:1.4.3")
+ include("io.leangen.geantyref:geantyref:1.3.16")
+
+ // Mod dependencies
+
+ include modImplementation("me.lucko:fabric-permissions-api:${project.permissions_api_version}")
+
+ include modImplementation("eu.pb4:placeholder-api:${project.placeholderapi_version}")
+ include modImplementation("eu.pb4:sgui:${project.sgui_version}")
+
+ modCompileOnly "dev.emi:trinkets:${project.trinkets_version}"
+ modRuntimeOnly "dev.emi:trinkets:${project.trinkets_version}"
+
+ modCompileOnly "net.luckperms:api:5.4"
+ modRuntimeOnly "net.luckperms:api:5.4"
+}
+
+loom {
+ def aw = project(':common').file("src/main/resources/${mod_id}.accesswidener")
+ if (aw.exists()) {
+ accessWidenerPath.set(aw)
+ }
+ mixin {
+ defaultRefmapName.set("${mod_id}.refmap.json")
+ }
+ runs {
+ server {
+ server()
+ setConfigName('Fabric Server')
+ ideConfigGenerated(true)
+ runDir('runs/server')
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/SolsticeFabric.java b/fabric/src/main/java/me/alexdevs/solstice/SolsticeFabric.java
new file mode 100644
index 0000000..1e32e7b
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/SolsticeFabric.java
@@ -0,0 +1,10 @@
+package me.alexdevs.solstice;
+
+import net.fabricmc.api.ModInitializer;
+
+public class SolsticeFabric implements ModInitializer {
+ @Override
+ public void onInitialize() {
+ Solstice.init();
+ }
+}
\ No newline at end of file
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/SolsticeMixinConfigPlugin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/SolsticeMixinConfigPlugin.java
new file mode 100644
index 0000000..cf2cc0d
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/SolsticeMixinConfigPlugin.java
@@ -0,0 +1,54 @@
+package me.alexdevs.solstice.mixin;
+
+import me.alexdevs.solstice.core.ToggleableConfig;
+import org.objectweb.asm.tree.ClassNode;
+import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
+import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
+
+import java.util.List;
+import java.util.Set;
+
+public class SolsticeMixinConfigPlugin implements IMixinConfigPlugin {
+ private final ToggleableConfig config = ToggleableConfig.get();
+ public static final String packageBase = "me.alexdevs.solstice.mixin.modules.";
+
+ @Override
+ public void onLoad(String mixinPackage) {
+ }
+
+ @Override
+ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
+ if (mixinClassName.startsWith(packageBase)) {
+ var moduleMixin = mixinClassName.replace(packageBase, "");
+ var parts = moduleMixin.split("\\.");
+ var module = parts[0].toLowerCase();
+ return config.isEnabled(module);
+ }
+ return true;
+ }
+
+ @Override
+ public String getRefMapperConfig() {
+ return null;
+ }
+
+ @Override
+ public void acceptTargets(Set myTargets, Set otherTargets) {
+
+ }
+
+ @Override
+ public List getMixins() {
+ return null;
+ }
+
+ @Override
+ public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
+
+ }
+
+ @Override
+ public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
+
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/events/CommandEventsMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/events/CommandEventsMixin.java
new file mode 100644
index 0000000..9b79471
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/events/CommandEventsMixin.java
@@ -0,0 +1,27 @@
+package me.alexdevs.solstice.mixin.events;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.ParseResults;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import me.alexdevs.solstice.api.events.CommandEvents;
+import net.minecraft.commands.CommandSourceStack;
+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.CallbackInfoReturnable;
+
+@Mixin(CommandDispatcher.class)
+public abstract class CommandEventsMixin {
+ @Inject(method = "execute(Lcom/mojang/brigadier/ParseResults;)I", at = @At("HEAD"), remap = false)
+ public void execute(ParseResults parse, CallbackInfoReturnable cir) throws CommandSyntaxException {
+ var context = parse.getContext();
+ if (context.getSource() instanceof CommandSourceStack source) {
+ var command = parse.getReader().getString();
+ if (!CommandEvents.ALLOW_COMMAND.invoker().allowCommand(source, command)) {
+ cir.cancel();
+ }
+
+ CommandEvents.COMMAND.invoker().onCommand(source, command);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/events/WorldSaveEventMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/events/WorldSaveEventMixin.java
new file mode 100644
index 0000000..facd046
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/events/WorldSaveEventMixin.java
@@ -0,0 +1,21 @@
+package me.alexdevs.solstice.mixin.events;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.api.events.WorldSaveCallback;
+import net.minecraft.server.MinecraftServer;
+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.CallbackInfoReturnable;
+
+@Mixin(MinecraftServer.class)
+public class WorldSaveEventMixin {
+ @Inject(method = "saveEverything", at = @At("TAIL"))
+ public void save(boolean suppressLogs, boolean flush, boolean force, CallbackInfoReturnable cir) {
+ try {
+ WorldSaveCallback.EVENT.invoker().onSave((MinecraftServer) (Object) this, suppressLogs, flush, force);
+ } catch (Exception e) {
+ SolsticeFabric.LOGGER.error("Exception emitting world save event", e);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/admin/ConnectionBypassMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/admin/ConnectionBypassMixin.java
new file mode 100644
index 0000000..e5d658b
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/admin/ConnectionBypassMixin.java
@@ -0,0 +1,33 @@
+package me.alexdevs.solstice.mixin.modules.admin;
+
+import com.mojang.authlib.GameProfile;
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.api.events.PlayerConnectionEvents;
+import net.minecraft.server.dedicated.DedicatedPlayerList;
+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.CallbackInfoReturnable;
+
+@Mixin(DedicatedPlayerList.class)
+public abstract class ConnectionBypassMixin {
+ @Inject(method = "isWhiteListed", at = @At("HEAD"), cancellable = true)
+ public void solstice$bypassWhitelist(GameProfile profile, CallbackInfoReturnable cir) {
+ try {
+ if (PlayerConnectionEvents.WHITELIST_BYPASS.invoker().bypassWhitelist(profile))
+ cir.setReturnValue(true);
+ } catch (Exception e) {
+ SolsticeFabric.LOGGER.error("Error checking whitelist bypass for profile {}", profile.getId(), e);
+ }
+ }
+
+ @Inject(method = "canBypassPlayerLimit", at = @At("HEAD"), cancellable = true)
+ public void solstice$bypassPlayerLimit(GameProfile profile, CallbackInfoReturnable cir) {
+ try {
+ if (PlayerConnectionEvents.FULL_SERVER_BYPASS.invoker().bypassFullServer(profile))
+ cir.setReturnValue(true);
+ } catch (Exception e) {
+ SolsticeFabric.LOGGER.error("Error checking full server bypass for profile {}", profile.getId(), e);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/afk/FixPlayerSleepPercentageMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/afk/FixPlayerSleepPercentageMixin.java
new file mode 100644
index 0000000..9fbf7ee
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/afk/FixPlayerSleepPercentageMixin.java
@@ -0,0 +1,19 @@
+package me.alexdevs.solstice.mixin.modules.afk;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.afk.AfkModule;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.players.SleepStatus;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Redirect;
+
+@Mixin(SleepStatus.class)
+public abstract class FixPlayerSleepPercentageMixin {
+ @Redirect(method = "update", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/level/ServerPlayer;isSpectator()Z"))
+ public boolean solstice$fixTotalPlayers(ServerPlayer player) {
+ var afkModule = SolsticeFabric.modules.getModule(AfkModule.class);
+
+ return player.isSpectator() || afkModule.isPlayerAfk(player);
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/back/PreTeleportMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/back/PreTeleportMixin.java
new file mode 100644
index 0000000..257a6fe
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/back/PreTeleportMixin.java
@@ -0,0 +1,23 @@
+package me.alexdevs.solstice.mixin.modules.back;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.api.ServerLocation;
+import me.alexdevs.solstice.modules.back.BackModule;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.RelativeMovement;
+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.CallbackInfoReturnable;
+
+import java.util.Set;
+
+@Mixin(ServerPlayer.class)
+public abstract class PreTeleportMixin {
+ @Inject(method = "teleportTo(Lnet/minecraft/server/level/ServerLevel;DDDLjava/util/Set;FF)Z", at = @At("HEAD"))
+ public void solstice$getPreTeleportLocation(ServerLevel world, double destX, double destY, double destZ, Set flags, float yaw, float pitch, CallbackInfoReturnable cir) {
+ var player = (ServerPlayer) (Object) this;
+ SolsticeFabric.modules.getModule(BackModule.class).lastPlayerPositions.put(player.getUUID(), new ServerLocation(player));
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/ban/CustomBanMessageMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/ban/CustomBanMessageMixin.java
new file mode 100644
index 0000000..7f830fd
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/ban/CustomBanMessageMixin.java
@@ -0,0 +1,32 @@
+package me.alexdevs.solstice.mixin.modules.ban;
+
+import com.llamalad7.mixinextras.sugar.Local;
+import com.mojang.authlib.GameProfile;
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.ban.formatters.BanMessageFormatter;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.server.players.PlayerList;
+import net.minecraft.server.players.UserBanListEntry;
+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.CallbackInfoReturnable;
+
+import java.net.SocketAddress;
+
+@Mixin(PlayerList.class)
+public abstract class CustomBanMessageMixin {
+ @Inject(method = "canPlayerLogin", at = @At(value = "RETURN", ordinal = 0), cancellable = true)
+ public void solstice$formatBanMessage(SocketAddress address, GameProfile profile, CallbackInfoReturnable cir, @Local UserBanListEntry bannedPlayerEntry, @Local MutableComponent mutableText) {
+ try {
+ var reasonText = BanMessageFormatter.format(profile, bannedPlayerEntry);
+ cir.setReturnValue(reasonText);
+ } catch (Exception ex) {
+ SolsticeFabric.LOGGER.error("Something went wrong while formatting the ban message", ex);
+
+ // Ensure the original text message is returned to avoid exploits and bypass the ban
+ cir.setReturnValue(mutableText);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/core/RealPingMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/core/RealPingMixin.java
new file mode 100644
index 0000000..d42ca26
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/core/RealPingMixin.java
@@ -0,0 +1,20 @@
+package me.alexdevs.solstice.mixin.modules.core;
+
+import com.llamalad7.mixinextras.sugar.Local;
+import net.minecraft.server.network.ServerCommonPacketListenerImpl;
+import org.objectweb.asm.Opcodes;
+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.Redirect;
+
+@Mixin(ServerCommonPacketListenerImpl.class)
+public abstract class RealPingMixin {
+ @Shadow
+ private int latency;
+
+ @Redirect(method = "handleKeepAlive", at = @At(value = "FIELD", target = "Lnet/minecraft/server/network/ServerCommonPacketListenerImpl;latency:I", opcode = Opcodes.PUTFIELD))
+ public void solstice$realPing(ServerCommonPacketListenerImpl instance, int value, @Local int i) {
+ latency = i;
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/customname/CustomDisplayNameMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/customname/CustomDisplayNameMixin.java
new file mode 100644
index 0000000..e6b3889
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/customname/CustomDisplayNameMixin.java
@@ -0,0 +1,27 @@
+package me.alexdevs.solstice.mixin.modules.customname;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.customName.CustomNameModule;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.player.Player;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+@Mixin(Player.class)
+public abstract class CustomDisplayNameMixin {
+ @Shadow
+ private MutableComponent decorateDisplayNameComponent(MutableComponent component) {
+ return null;
+ }
+
+ @Inject(method = "getDisplayName", at = @At("HEAD"), cancellable = true)
+ public void solstice$getDisplayName(CallbackInfoReturnable cir) {
+ var customNameModule = SolsticeFabric.modules.getModule(CustomNameModule.class);
+ var name = customNameModule.getNameForPlayer((ServerPlayer) (Object) this);
+ cir.setReturnValue(decorateDisplayNameComponent(name));
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/miscellaneous/BypassSleepingInBedCheckMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/miscellaneous/BypassSleepingInBedCheckMixin.java
new file mode 100644
index 0000000..fe98f2d
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/miscellaneous/BypassSleepingInBedCheckMixin.java
@@ -0,0 +1,20 @@
+package me.alexdevs.solstice.mixin.modules.miscellaneous;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.miscellaneous.MiscellaneousModule;
+import net.minecraft.world.entity.LivingEntity;
+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.CallbackInfoReturnable;
+
+@Mixin(LivingEntity.class)
+public abstract class BypassSleepingInBedCheckMixin {
+ @Inject(method = "checkBedExists", at = @At("HEAD"), cancellable = true)
+ private void isSleepingInBed(CallbackInfoReturnable cir) {
+ var module = SolsticeFabric.modules.getModule(MiscellaneousModule.class);
+ if (module.isCommandSleep((LivingEntity) (Object) this)) {
+ cir.setReturnValue(true);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/sign/FormatSignMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/sign/FormatSignMixin.java
new file mode 100644
index 0000000..1607230
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/sign/FormatSignMixin.java
@@ -0,0 +1,31 @@
+package me.alexdevs.solstice.mixin.modules.sign;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.sign.SignModule;
+import net.minecraft.server.network.FilteredText;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.level.block.entity.SignBlockEntity;
+import net.minecraft.world.level.block.entity.SignText;
+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.CallbackInfoReturnable;
+
+import java.util.List;
+
+@Mixin(SignBlockEntity.class)
+public abstract class FormatSignMixin {
+
+ @Inject(method = "setMessages", at = @At("HEAD"), cancellable = true)
+ private void solstice$formatSignText(Player player, List messages, SignText text, CallbackInfoReturnable cir) {
+ var formattableSignsModule = SolsticeFabric.modules.getModule(SignModule.class);
+ if (formattableSignsModule.canFormatSign(player)) {
+ try {
+ text = SignModule.formatSign(messages, text);
+ cir.setReturnValue(text);
+ } catch (Exception e) {
+ SolsticeFabric.LOGGER.error("Something went wrong while formatting a sign!", e);
+ }
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/spawn/OverrideSpawnPointMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/spawn/OverrideSpawnPointMixin.java
new file mode 100644
index 0000000..b737d49
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/spawn/OverrideSpawnPointMixin.java
@@ -0,0 +1,69 @@
+package me.alexdevs.solstice.mixin.modules.spawn;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.spawn.SpawnModule;
+import net.minecraft.core.BlockPos;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.portal.DimensionTransition;
+import net.minecraft.world.phys.Vec3;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+@Mixin(ServerPlayer.class)
+public abstract class OverrideSpawnPointMixin {
+ @Shadow
+ @Final
+ public MinecraftServer server;
+
+ @Inject(method = "getRespawnPosition", at = @At("RETURN"), cancellable = true)
+ public void solstice$overrideSpawnPos(CallbackInfoReturnable cir) {
+ var spawnModule = SolsticeFabric.modules.getModule(SpawnModule.class);
+ var config = spawnModule.getConfig();
+ if (config.globalSpawn.onRespawn) {
+ var pos = spawnModule.getGlobalSpawnPosition().getBlockPos();
+ cir.setReturnValue(pos);
+ }
+ }
+
+ @Inject(method = "getRespawnDimension", at = @At("RETURN"), cancellable = true)
+ public void solstice$overrideSpawnDimension(CallbackInfoReturnable> cir) {
+ var spawnModule = SolsticeFabric.modules.getModule(SpawnModule.class);
+ var config = spawnModule.getConfig();
+ if (config.globalSpawn.onRespawn) {
+ cir.setReturnValue(spawnModule.getGlobalSpawnWorld().dimension());
+ }
+ }
+
+ @Inject(method = "findRespawnPositionAndUseSpawnBlock", at = @At("RETURN"), cancellable = true)
+ public void solstice$overrideRespawnTarget(boolean keepInventory, DimensionTransition.PostDimensionTransition postDimensionTransition, CallbackInfoReturnable cir) {
+ var spawnModule = SolsticeFabric.modules.getModule(SpawnModule.class);
+ var config = spawnModule.getConfig();
+ if (config.globalSpawn.onRespawn) {
+ var spawn = spawnModule.getGlobalSpawnPosition();
+
+ var world = spawn.getWorld(this.server);
+ var pos = new Vec3(
+ spawn.getX(),
+ spawn.getY(),
+ spawn.getZ()
+ );
+
+ cir.setReturnValue(new DimensionTransition(
+ world,
+ pos,
+ Vec3.ZERO,
+ spawn.getYaw(),
+ spawn.getPitch(),
+ false,
+ DimensionTransition.DO_NOTHING
+ ));
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomAdvancementMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomAdvancementMixin.java
new file mode 100644
index 0000000..ac7eba7
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomAdvancementMixin.java
@@ -0,0 +1,20 @@
+package me.alexdevs.solstice.mixin.modules.styling;
+
+import me.alexdevs.solstice.modules.styling.formatters.AdvancementFormatter;
+import net.minecraft.advancements.AdvancementHolder;
+import net.minecraft.advancements.AdvancementType;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.server.level.ServerPlayer;
+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.CallbackInfoReturnable;
+
+@Mixin(AdvancementType.class)
+public abstract class CustomAdvancementMixin {
+
+ @Inject(method = "createAnnouncement", at = @At("HEAD"), cancellable = true)
+ public void solstice$getCustomAnnouncement(AdvancementHolder advancement, ServerPlayer player, CallbackInfoReturnable cir) {
+ cir.setReturnValue(AdvancementFormatter.getText(player, advancement, (AdvancementType) (Object) this).copy());
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomChatMessageMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomChatMessageMixin.java
new file mode 100644
index 0000000..a1de4fd
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomChatMessageMixin.java
@@ -0,0 +1,24 @@
+package me.alexdevs.solstice.mixin.modules.styling;
+
+import com.llamalad7.mixinextras.sugar.Local;
+import me.alexdevs.solstice.modules.styling.CustomSentMessage;
+import net.minecraft.network.chat.OutgoingChatMessage;
+import net.minecraft.network.chat.PlayerChatMessage;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.players.PlayerList;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Redirect;
+
+@Mixin(PlayerList.class)
+public abstract class CustomChatMessageMixin {
+ @Redirect(
+ method = "broadcastChatMessage(Lnet/minecraft/network/chat/PlayerChatMessage;Ljava/util/function/Predicate;Lnet/minecraft/server/level/ServerPlayer;Lnet/minecraft/network/chat/ChatType$Bound;)V",
+ at = @At(
+ value = "INVOKE",
+ target = "Lnet/minecraft/network/chat/OutgoingChatMessage;create(Lnet/minecraft/network/chat/PlayerChatMessage;)Lnet/minecraft/network/chat/OutgoingChatMessage;")
+ )
+ private OutgoingChatMessage solstice$broadcast(PlayerChatMessage message, @Local(argsOnly = true) ServerPlayer sender) {
+ return CustomSentMessage.of(message, sender);
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomConnectionMessagesMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomConnectionMessagesMixin.java
new file mode 100644
index 0000000..3f55856
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomConnectionMessagesMixin.java
@@ -0,0 +1,43 @@
+package me.alexdevs.solstice.mixin.modules.styling;
+
+import me.alexdevs.solstice.modules.styling.formatters.ConnectionActivityFormatter;
+import net.minecraft.network.Connection;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.contents.TranslatableContents;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.network.CommonListenerCookie;
+import net.minecraft.server.players.PlayerList;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.ModifyArg;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin(PlayerList.class)
+public abstract class CustomConnectionMessagesMixin {
+ @Unique
+ private ServerPlayer solstice$player = null;
+
+ @Inject(method = "placeNewPlayer", at = @At("HEAD"))
+ private void solstice$onJoin(Connection connection, ServerPlayer player, CommonListenerCookie cookie, CallbackInfo ci) {
+ solstice$player = player;
+ }
+
+ @Inject(method = "placeNewPlayer", at = @At("RETURN"))
+ private void solstice$onJoinReturn(Connection connection, ServerPlayer player, CommonListenerCookie cookie, CallbackInfo ci) {
+ solstice$player = null;
+ }
+
+ @ModifyArg(method = "placeNewPlayer", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/players/PlayerList;broadcastSystemMessage(Lnet/minecraft/network/chat/Component;Z)V"))
+ public Component solstice$getPlayerJoinMessage(Component message) {
+ var ogText = (TranslatableContents) message.getContents();
+ var args = ogText.getArgs();
+
+ if (args.length == 1) {
+ return ConnectionActivityFormatter.onJoin(solstice$player);
+ } else {
+ return ConnectionActivityFormatter.onJoinRenamed(solstice$player, (String) args[1]);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomDeathMessageMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomDeathMessageMixin.java
new file mode 100644
index 0000000..c31e255
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomDeathMessageMixin.java
@@ -0,0 +1,18 @@
+package me.alexdevs.solstice.mixin.modules.styling;
+
+import me.alexdevs.solstice.modules.styling.formatters.DeathFormatter;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.damagesource.CombatTracker;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Redirect;
+
+@Mixin(ServerPlayer.class)
+public abstract class CustomDeathMessageMixin {
+ @Redirect(method = "die", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/damagesource/CombatTracker;getDeathMessage()Lnet/minecraft/network/chat/Component;"))
+ private Component solstice$getDeathMessage(CombatTracker instance) {
+ var player = (ServerPlayer) (Object) this;
+ return DeathFormatter.onDeath(player, instance);
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomSentMessageMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomSentMessageMixin.java
new file mode 100644
index 0000000..18dff2f
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/CustomSentMessageMixin.java
@@ -0,0 +1,23 @@
+package me.alexdevs.solstice.mixin.modules.styling;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.styling.CustomSentMessage;
+import net.minecraft.network.chat.OutgoingChatMessage;
+import net.minecraft.network.chat.PlayerChatMessage;
+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.CallbackInfoReturnable;
+
+@Mixin(OutgoingChatMessage.class)
+public interface CustomSentMessageMixin {
+ @Inject(method = "create", at = @At("HEAD"), cancellable = true)
+ private static void solstice$of(PlayerChatMessage message, CallbackInfoReturnable cir) {
+ if (message.isSystem()) {
+ cir.setReturnValue(new CustomSentMessage.Profileless(message.decoratedContent()));
+ } else {
+ var sender = SolsticeFabric.server.getPlayerList().getPlayer(message.sender());
+ cir.setReturnValue(new CustomSentMessage.Chat(message, sender));
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/InjectCustomChatMessageMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/InjectCustomChatMessageMixin.java
new file mode 100644
index 0000000..0dc31e1
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/InjectCustomChatMessageMixin.java
@@ -0,0 +1,54 @@
+package me.alexdevs.solstice.mixin.modules.styling;
+
+import com.mojang.datafixers.util.Pair;
+import me.alexdevs.solstice.modules.styling.StylingModule;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.resources.RegistryDataLoader;
+import net.minecraft.resources.RegistryOps;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Coerce;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+import org.spongepowered.asm.mixin.injection.callback.LocalCapture;
+
+import java.util.List;
+import java.util.Map;
+
+@Mixin(RegistryDataLoader.class)
+public class InjectCustomChatMessageMixin {
+
+ /*
+ @SuppressWarnings("unchecked")
+ @Inject(method = "load(Lnet/minecraft/resource/ResourceManager;Lnet/minecraft/registry/DynamicRegistryManager;Ljava/util/List;)Lnet/minecraft/registry/DynamicRegistryManager$Immutable;", at = @At(value = "INVOKE", target = "Ljava/util/List;forEach(Ljava/util/function/Consumer;)V", ordinal = 0, shift = At.Shift.AFTER), locals = LocalCapture.CAPTURE_FAILEXCEPTION)
+ private static void solstice$load(ResourceManager resourceManager, DynamicRegistryManager baseRegistryManager, List> entries,
+ CallbackInfoReturnable cir, Map _unused, List, Object>> list) {
+ for (var pair : list) {
+ var registry = pair.getFirst();
+ if (registry.getKey().equals(RegistryKeys.MESSAGE_TYPE)) {
+ Registry.register((Registry) registry, StylingModule.CHAT_TYPE,
+ new MessageType(
+ Decoration.ofChat("%s"),
+ Decoration.ofChat("%s")
+ ));
+ }
+ }
+ }*/
+
+ /*@SuppressWarnings("unchecked")
+ @Inject(method = "Lnet/minecraft/resources/RegistryDataLoader;load(Lnet/minecraft/resources/RegistryDataLoader$LoadingFunction;Lnet/minecraft/core/RegistryAccess;Ljava/util/List;)Lnet/minecraft/core/RegistryAccess$Frozen;",
+ at = @At(value = "INVOKE", target = "Ljava/util/List;forEach(Ljava/util/function/Consumer;)V",
+ ordinal = 0, shift = At.Shift.AFTER), locals = LocalCapture.CAPTURE_FAILEXCEPTION)
+ private static void solstice$load(RegistryDataLoader.LoadingFunction loadingFunction, RegistryAccess registryAccess, List> registryData, CallbackInfoReturnable cir, Map map, List list, RegistryOps.RegistryInfoLookup registryInfoLookup) {
+ for (var entry : entries) {
+ var registry = entry.key();
+ if (registry.getRegistry().equals(RegistryKeys.MESSAGE_TYPE)) {
+ Registry.register((Registry) registry, StylingModule.CHAT_TYPE,
+ new MessageType(
+ Decoration.ofChat("%s"),
+ Decoration.ofChat("%s")
+ ));
+ }
+ }
+ }*/
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/PlayerDisconnectMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/PlayerDisconnectMixin.java
new file mode 100644
index 0000000..75230cf
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/styling/PlayerDisconnectMixin.java
@@ -0,0 +1,21 @@
+package me.alexdevs.solstice.mixin.modules.styling;
+
+import me.alexdevs.solstice.modules.styling.formatters.ConnectionActivityFormatter;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+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.ModifyArg;
+
+@Mixin(ServerGamePacketListenerImpl.class)
+public abstract class PlayerDisconnectMixin {
+ @Shadow
+ public ServerPlayer player;
+
+ @ModifyArg(method = "removePlayerFromWorld", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/players/PlayerList;broadcastSystemMessage(Lnet/minecraft/network/chat/Component;Z)V"))
+ private Component solstice$getPlayerLeaveMessage(Component message) {
+ return ConnectionActivityFormatter.onLeave(this.player);
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/tablist/CustomPlayerListNameMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/tablist/CustomPlayerListNameMixin.java
new file mode 100644
index 0000000..cb2ed69
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/tablist/CustomPlayerListNameMixin.java
@@ -0,0 +1,25 @@
+package me.alexdevs.solstice.mixin.modules.tablist;
+
+import eu.pb4.placeholders.api.PlaceholderContext;
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.api.text.Format;
+import me.alexdevs.solstice.modules.tablist.data.TabListConfig;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+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.CallbackInfoReturnable;
+
+@Mixin(ServerPlayer.class)
+public abstract class CustomPlayerListNameMixin {
+ @Inject(method = "getTabListDisplayName", at = @At("HEAD"), cancellable = true)
+ private void solstice$customizePlayerListName(CallbackInfoReturnable callback) {
+ if (SolsticeFabric.configManager.getData(TabListConfig.class).enable) {
+ var player = (ServerPlayer) (Object) this;
+ var playerContext = PlaceholderContext.of(player);
+ var text = Format.parse(SolsticeFabric.configManager.getData(TabListConfig.class).playerTabName, playerContext);
+ callback.setReturnValue(text);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/tablist/UpdatePlayerListMixin.java b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/tablist/UpdatePlayerListMixin.java
new file mode 100644
index 0000000..5ed5c07
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/mixin/modules/tablist/UpdatePlayerListMixin.java
@@ -0,0 +1,29 @@
+package me.alexdevs.solstice.mixin.modules.tablist;
+
+import me.alexdevs.solstice.SolsticeFabric;
+import me.alexdevs.solstice.modules.tablist.data.TabListConfig;
+import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.server.network.ServerGamePacketListenerImpl;
+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.util.EnumSet;
+import java.util.List;
+
+@Mixin(ServerGamePacketListenerImpl.class)
+public abstract class UpdatePlayerListMixin {
+ @Shadow
+ public ServerPlayer player;
+
+ @Inject(method = "tick", at = @At("TAIL"))
+ private void solstice$updatePlayerList(CallbackInfo ci) {
+ if (SolsticeFabric.configManager.getData(TabListConfig.class).enable) {
+ var packet = new ClientboundPlayerInfoUpdatePacket(EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME, ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED), List.of(this.player));
+ this.player.getServer().getPlayerList().broadcastAll(packet);
+ }
+ }
+}
diff --git a/fabric/src/main/java/me/alexdevs/solstice/platform/FabricPlatformHelper.java b/fabric/src/main/java/me/alexdevs/solstice/platform/FabricPlatformHelper.java
new file mode 100644
index 0000000..9394e01
--- /dev/null
+++ b/fabric/src/main/java/me/alexdevs/solstice/platform/FabricPlatformHelper.java
@@ -0,0 +1,47 @@
+package me.alexdevs.solstice.platform;
+
+import me.alexdevs.solstice.Constants;
+import me.alexdevs.solstice.platform.services.IPlatformHelper;
+import net.fabricmc.loader.api.FabricLoader;
+
+import java.nio.file.Path;
+
+public class FabricPlatformHelper implements IPlatformHelper {
+
+ @Override
+ public Platform getPlatformName() {
+ return Platform.FABRIC;
+ }
+
+ @Override
+ public boolean isModLoaded(String modId) {
+ return FabricLoader.getInstance().isModLoaded(modId);
+ }
+
+ @Override
+ public boolean isDevelopmentEnvironment() {
+ return FabricLoader.getInstance().isDevelopmentEnvironment();
+ }
+
+ @Override
+ public String getVersion() {
+ var container = FabricLoader.getInstance().getModContainer(Constants.MOD_ID).orElseThrow();
+ return container.getMetadata().getVersion().getFriendlyString();
+ }
+
+ @Override
+ public String getLoaderVersion() {
+ var container = FabricLoader.getInstance().getModContainer("fabric-api").orElseThrow();
+ return container.getMetadata().getVersion().getFriendlyString();
+ }
+
+ @Override
+ public Path getConfigDir() {
+ return FabricLoader.getInstance().getConfigDir().resolve(Constants.MOD_ID);
+ }
+
+ @Override
+ public Path getGameDir() {
+ return FabricLoader.getInstance().getGameDir();
+ }
+}
diff --git a/fabric/src/main/resources/META-INF/services/me.alexdevs.solstice.platform.services.IPlatformHelper b/fabric/src/main/resources/META-INF/services/me.alexdevs.solstice.platform.services.IPlatformHelper
new file mode 100644
index 0000000..9b2fa73
--- /dev/null
+++ b/fabric/src/main/resources/META-INF/services/me.alexdevs.solstice.platform.services.IPlatformHelper
@@ -0,0 +1 @@
+me.alexdevs.solstice.platform.FabricPlatformHelper
diff --git a/fabric/src/main/resources/fabric.mod.json b/fabric/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..59da754
--- /dev/null
+++ b/fabric/src/main/resources/fabric.mod.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 1,
+ "id": "${mod_id}",
+ "version": "${version}",
+ "name": "${mod_name}",
+ "description": "${description}",
+ "authors": [
+ "${mod_author}"
+ ],
+ "contact": {
+ "sources": "https://github.com/Ale32bit/Solstice.git",
+ "issues": "https://github.com/Ale32bit/Solstice/issues",
+ "homepage": "https://solstice.alexdevs.me",
+ "email": "solstice@alexdevs.me"
+ },
+ "license": "${license}",
+ "icon": "${mod_id}.png",
+ "environment": "*",
+ "entrypoints": {
+ "main": [
+ "me.alexdevs.solstice.SolsticeFabric"
+ ],
+ "solstice": [
+ "me.alexdevs.solstice.modules.ModuleProvider"
+ ]
+ },
+ "mixins": [
+ "${mod_id}.mixins.json",
+ "${mod_id}.fabric.mixins.json"
+ ],
+ "depends": {
+ "fabricloader": ">=${fabric_loader_version}",
+ "fabric-api": "*",
+ "minecraft": "${minecraft_version}",
+ "java": ">=${java_version}"
+ },
+ "recommends": {
+ "luckperms": "*"
+ },
+ "accessWidener": "solstice.accesswidener"
+}
diff --git a/fabric/src/main/resources/solstice.fabric.mixins.json b/fabric/src/main/resources/solstice.fabric.mixins.json
new file mode 100644
index 0000000..11fef50
--- /dev/null
+++ b/fabric/src/main/resources/solstice.fabric.mixins.json
@@ -0,0 +1,34 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "me.alexdevs.solstice.mixin",
+ "compatibilityLevel": "JAVA_21",
+ "plugin": "me.alexdevs.solstice.mixin.SolsticeMixinConfigPlugin",
+ "client": [],
+ "server": [],
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "mixins": [
+ "events.CommandEventsMixin",
+ "events.WorldSaveEventMixin",
+ "modules.admin.ConnectionBypassMixin",
+ "modules.afk.FixPlayerSleepPercentageMixin",
+ "modules.back.PreTeleportMixin",
+ "modules.ban.CustomBanMessageMixin",
+ "modules.core.RealPingMixin",
+ "modules.customname.CustomDisplayNameMixin",
+ "modules.miscellaneous.BypassSleepingInBedCheckMixin",
+ "modules.sign.FormatSignMixin",
+ "modules.spawn.OverrideSpawnPointMixin",
+ "modules.styling.CustomAdvancementMixin",
+ "modules.styling.CustomChatMessageMixin",
+ "modules.styling.CustomConnectionMessagesMixin",
+ "modules.styling.CustomDeathMessageMixin",
+ "modules.styling.CustomSentMessageMixin",
+ "modules.styling.InjectCustomChatMessageMixin",
+ "modules.styling.PlayerDisconnectMixin",
+ "modules.tablist.CustomPlayerListNameMixin",
+ "modules.tablist.UpdatePlayerListMixin"
+ ]
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..229200e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,38 @@
+# Important Notes:
+# Every field you add must be added to the root build.gradle expandProps map.
+# Project
+version=2.0.0
+group=me.alexdevs
+java_version=21
+# Common
+minecraft_version=1.21
+mod_name=Solstice
+mod_author=
+mod_id=solstice
+license=MIT
+credits=
+description=
+minecraft_version_range=[1.21, 1.22)
+neo_form_version=1.21-20240613.152323
+# The version of ParchmentMC that is used, see https://parchmentmc.org/docs/getting-started#choose-a-version for new versions
+parchment_minecraft=1.21
+parchment_version=2024.06.23
+# Fabric
+fabric_version=0.100.1+1.21
+fabric_loader_version=0.15.11
+# Forge
+forge_version=51.0.17
+forge_loader_version_range=[51,)
+# NeoForge
+neoforge_version=21.0.37-beta
+neoforge_loader_version_range=[4,)
+# Gradle
+org.gradle.jvmargs=-Xmx3G
+org.gradle.daemon=false
+
+configurate_version=4.1.2
+permissions_api_version=0.2-SNAPSHOT
+placeholderapi_version=2.4.2+1.21
+sgui_version=1.6.1+1.21.1
+
+trinkets_version=3.10.0
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796
GIT binary patch
literal 43453
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 yu?e%!_ptzGB|8GrCfu%p?(_
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