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 0000000..e644113
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a441313
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..b740cf1
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..25da30d
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/neoforge/build.gradle b/neoforge/build.gradle
new file mode 100644
index 0000000..be1f866
--- /dev/null
+++ b/neoforge/build.gradle
@@ -0,0 +1,33 @@
+plugins {
+ id 'multiloader-loader'
+ id 'net.neoforged.moddev'
+}
+
+neoForge {
+ version = neoforge_version
+ // Automatically enable neoforge AccessTransformers if the file exists
+ def at = project(':common').file('src/main/resources/META-INF/accesstransformer.cfg')
+ if (at.exists()) {
+ accessTransformers.add(at.absolutePath)
+ }
+ parchment {
+ minecraftVersion = parchment_minecraft
+ mappingsVersion = parchment_version
+ }
+ runs {
+ configureEach {
+ systemProperty('neoforge.enabledGameTestNamespaces', mod_id)
+ ideName = "NeoForge ${it.name.capitalize()} (${project.path})" // Unify the run config names with fabric
+ }
+ server {
+ server()
+ }
+ }
+ mods {
+ "${mod_id}" {
+ sourceSet sourceSets.main
+ }
+ }
+}
+
+sourceSets.main.resources { srcDir 'src/generated/resources' }
diff --git a/neoforge/src/main/java/me/alexdevs/solstice/Solstice.java b/neoforge/src/main/java/me/alexdevs/solstice/Solstice.java
new file mode 100644
index 0000000..eb9b207
--- /dev/null
+++ b/neoforge/src/main/java/me/alexdevs/solstice/Solstice.java
@@ -0,0 +1,19 @@
+package me.alexdevs.solstice;
+
+
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.fml.common.Mod;
+
+@Mod(Constants.MOD_ID)
+public class Solstice {
+
+ public Solstice(IEventBus eventBus) {
+ // This method is invoked by the NeoForge mod loader when it is ready
+ // to load your mod. You can access NeoForge and Common code in this
+ // project.
+
+ // Use NeoForge to bootstrap the Common mod.
+ Constants.LOG.info("Hello NeoForge world!");
+ Solstice.init();
+ }
+}
diff --git a/neoforge/src/main/java/me/alexdevs/solstice/mixin/MixinTitleScreen.java b/neoforge/src/main/java/me/alexdevs/solstice/mixin/MixinTitleScreen.java
new file mode 100644
index 0000000..c9c2f95
--- /dev/null
+++ b/neoforge/src/main/java/me/alexdevs/solstice/mixin/MixinTitleScreen.java
@@ -0,0 +1,19 @@
+package me.alexdevs.solstice.mixin;
+
+import me.alexdevs.solstice.Constants;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.screens.TitleScreen;
+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(TitleScreen.class)
+public class MixinTitleScreen {
+
+ @Inject(at = @At("HEAD"), method = "init()V")
+ private void init(CallbackInfo info) {
+ Constants.LOG.info("This line is printed by the Solstice mixin from NeoForge!");
+ Constants.LOG.info("MC Version: {}", Minecraft.getInstance().getVersionType());
+ }
+}
diff --git a/neoforge/src/main/java/me/alexdevs/solstice/platform/NeoForgePlatformHelper.java b/neoforge/src/main/java/me/alexdevs/solstice/platform/NeoForgePlatformHelper.java
new file mode 100644
index 0000000..2ebef45
--- /dev/null
+++ b/neoforge/src/main/java/me/alexdevs/solstice/platform/NeoForgePlatformHelper.java
@@ -0,0 +1,46 @@
+package me.alexdevs.solstice.platform;
+
+import me.alexdevs.solstice.Constants;
+import me.alexdevs.solstice.platform.services.IPlatformHelper;
+import net.neoforged.fml.ModList;
+import net.neoforged.fml.loading.FMLLoader;
+
+import java.nio.file.Path;
+
+public class NeoForgePlatformHelper implements IPlatformHelper {
+
+ @Override
+ public Platform getPlatformName() {
+ return Platform.NEOFORGE;
+ }
+
+ @Override
+ public boolean isModLoaded(String modId) {
+ return ModList.get().isLoaded(modId);
+ }
+
+ @Override
+ public boolean isDevelopmentEnvironment() {
+ return !FMLLoader.isProduction();
+ }
+
+ @Override
+ public String getVersion() {
+ return "./config/";
+ }
+
+ @Override
+ public String getLoaderVersion() {
+ return "";
+ }
+
+ @Override
+ public Path getConfigDir() {
+ return Path.of("./config/", Constants.MOD_ID);
+ }
+
+ @Override
+ public Path getGameDir() {
+ return Path.of(".");
+ }
+}
diff --git a/neoforge/src/main/resources/META-INF/neoforge.mods.toml b/neoforge/src/main/resources/META-INF/neoforge.mods.toml
new file mode 100644
index 0000000..a476356
--- /dev/null
+++ b/neoforge/src/main/resources/META-INF/neoforge.mods.toml
@@ -0,0 +1,36 @@
+modLoader = "javafml" #mandatory
+loaderVersion = "${neoforge_loader_version_range}" #mandatory
+license = "${license}" # Review your options at https://choosealicense.com/.
+#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional
+[[mods]] #mandatory
+modId = "${mod_id}" #mandatory
+version = "${version}" #mandatory
+displayName = "${mod_name}" #mandatory
+#updateJSONURL="https://change.me.example.invalid/updates.json" #optional, see https://docs.neoforged.net/docs/misc/updatechecker/
+#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional, displayed in the mod UI
+logoFile = "${mod_id}.png" #optional
+credits = "${credits}" #optional
+authors = "${mod_author}" #optional
+description = '''${description}''' #mandatory Supports multiline text
+[[mixins]]
+config = "${mod_id}.mixins.json"
+[[mixins]]
+config = "${mod_id}.neoforge.mixins.json"
+[[dependencies."${mod_id}"]] #optional
+modId = "neoforge" #mandatory
+type = "required" #mandatory Can be one of "required", "optional", "incompatible" or "discouraged"
+versionRange = "[${neoforge_version},)" #mandatory
+ordering = "NONE" # The order that this dependency should load in relation to your mod, required to be either 'BEFORE' or 'AFTER' if the dependency is not mandatory
+side = "BOTH" # Side this dependency is applied on - 'BOTH', 'CLIENT' or 'SERVER'
+[[dependencies."${mod_id}"]]
+modId = "minecraft"
+type = "required" #mandatory Can be one of "required", "optional", "incompatible" or "discouraged"
+versionRange = "${minecraft_version_range}"
+ordering = "NONE"
+side = "BOTH"
+
+# Features are specific properties of the game environment, that you may want to declare you require. This example declares
+# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't
+# stop your mod loading on the server for example.
+#[features.${mod_id}]
+#openGLVersion="[3.2,)"
diff --git a/neoforge/src/main/resources/META-INF/services/me.alexdevs.solstice.platform.services.IPlatformHelper b/neoforge/src/main/resources/META-INF/services/me.alexdevs.solstice.platform.services.IPlatformHelper
new file mode 100644
index 0000000..7699d8e
--- /dev/null
+++ b/neoforge/src/main/resources/META-INF/services/me.alexdevs.solstice.platform.services.IPlatformHelper
@@ -0,0 +1 @@
+me.alexdevs.solstice.platform.NeoForgePlatformHelper
diff --git a/neoforge/src/main/resources/solstice.neoforge.mixins.json b/neoforge/src/main/resources/solstice.neoforge.mixins.json
new file mode 100644
index 0000000..8d1b461
--- /dev/null
+++ b/neoforge/src/main/resources/solstice.neoforge.mixins.json
@@ -0,0 +1,14 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "me.alexdevs.solstice.mixin",
+ "compatibilityLevel": "JAVA_21",
+ "mixins": [],
+ "client": [
+ "MixinTitleScreen"
+ ],
+ "server": [],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..e2d770b
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,50 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ exclusiveContent {
+ forRepository {
+ maven {
+ name = 'Fabric'
+ url = uri('https://maven.fabricmc.net')
+ }
+ }
+ filter {
+ includeGroup('net.fabricmc')
+ includeGroup('fabric-loom')
+ }
+ }
+ exclusiveContent {
+ forRepository {
+ maven {
+ name = 'Sponge'
+ url = uri('https://repo.spongepowered.org/repository/maven-public')
+ }
+ }
+ filter {
+ includeGroupAndSubgroups("org.spongepowered")
+ }
+ }
+ exclusiveContent {
+ forRepository {
+ maven {
+ name = 'Forge'
+ url = uri('https://maven.minecraftforge.net')
+ }
+ }
+ filter {
+ includeGroupAndSubgroups('net.minecraftforge')
+ }
+ }
+ }
+}
+
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
+}
+
+// This should match the folder name of the project, or else IDEA may complain (see https://youtrack.jetbrains.com/issue/IDEA-317606)
+rootProject.name = 'MultiSolstice'
+include('common')
+include('fabric')
+include('neoforge')