# Firefrost Rules Mod β€” Complete Implementation Package **Generated:** March 28, 2026 **By:** Gemini (Parts 1 & 2) + Claude (Chronicler #46) **For:** Michael (The Wizard) β€” Desktop implementation **Status:** Production-ready, complete package --- ## πŸ“‹ **Overview** This is the complete, production-ready NeoForge 1.21.1 mod for displaying server rules via `/rules` command. **Features:** - βœ… Fetches rules from Discord messages (Holly can edit easily) - βœ… Auto-converts emojis (πŸ”₯β†’[Fire], ❄️→[Frost], πŸ’œβ†’[Arcane]) - βœ… 60-second per-player cooldown (prevents spam) - βœ… 30-minute cache (reduces Discord API calls) - βœ… Async HTTP execution (zero TPS impact) - βœ… Silent fallback if Discord fails (uses cached rules) - βœ… Hot-reload config support (no server restart needed) - βœ… Fire/Frost/Arcane color scheme auto-detection --- ## πŸ—οΈ **Project Structure** ``` FirefrostRules/ β”œβ”€β”€ gradle.properties β”œβ”€β”€ build.gradle └── src/main/ β”œβ”€β”€ java/com/firefrostgaming/rules/ β”‚ β”œβ”€β”€ ServerRules.java (main mod class) β”‚ β”œβ”€β”€ ServerRulesConfig.java (config system) β”‚ β”œβ”€β”€ DiscordFetcher.java (async HTTP client) β”‚ β”œβ”€β”€ RulesCache.java (local cache) β”‚ β”œβ”€β”€ DiscordFormatter.java (markdown β†’ components) β”‚ β”œβ”€β”€ CooldownManager.java (per-player cooldown) β”‚ └── RulesCommand.java (command handler) └── resources/META-INF/ └── neoforge.mods.toml ``` --- ## πŸ“¦ **Build Files** ### gradle.properties ```properties org.gradle.jvmargs=-Xmx3G org.gradle.daemon=false # NeoForge 1.21.1 versions minecraft_version=1.21.1 neo_version=21.1.61 mod_id=firefrostrules mod_name=Firefrost Rules mod_version=1.0.0 mod_group_id=com.firefrostgaming.rules ``` ### build.gradle ```gradle plugins { id 'java-library' id 'net.neoforged.moddev' version '2.0.74-beta' } version = mod_version group = mod_group_id repositories { mavenCentral() } // Zero external dependencies - using Java 21 HttpClient and bundled Gson dependencies { } neoForge { version = neo_version runs { server { server() systemProperty 'neoforge.enableGameTest', 'true' systemProperty 'neoforge.showVulnerabilities', 'true' } } mods { "${mod_id}" { sourceSet sourceSets.main } } } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' options.release.set(21) } ``` ### src/main/resources/META-INF/neoforge.mods.toml ```toml modLoader="javafml" loaderVersion="[21,)" license="All Rights Reserved" issueTrackerURL="https://firefrostgaming.com/support" showAsResourcePack=false [[mods]] modId="firefrostrules" version="${file.jarVersion}" displayName="Firefrost Rules" displayURL="https://firefrostgaming.com" authors="Firefrost Gaming" description=''' Fetches server rules dynamically from Discord for the /rules command. Built for long-term stability and easy community management. ''' # Server-side only mod [[dependencies.firefrostrules]] modId="neoforge" type="required" versionRange="[21.1.61,)" ordering="NONE" side="SERVER" [[dependencies.firefrostrules]] modId="minecraft" type="required" versionRange="[1.21.1,1.22)" ordering="NONE" side="SERVER" ``` --- ## πŸ’» **Java Classes** ### 1. ServerRulesConfig.java **IMPORTANT:** Add `CHANNEL_ID` config value (line 15-17 below) to avoid hardcoding. ```java package com.firefrostgaming.rules; import net.neoforged.neoforge.common.ModConfigSpec; import org.apache.commons.lang3.StringUtils; public class ServerRulesConfig { public static final ModConfigSpec SPEC; public static final ModConfigSpec.ConfigValue BOT_TOKEN; public static final ModConfigSpec.ConfigValue CHANNEL_ID; public static final ModConfigSpec.ConfigValue MESSAGE_ID; public static final ModConfigSpec.IntValue COOLDOWN_SECONDS; public static final ModConfigSpec.IntValue CACHE_MINUTES; static { ModConfigSpec.Builder builder = new ModConfigSpec.Builder(); builder.push("discord"); BOT_TOKEN = builder .comment("Discord Bot Token (Requires read access to the rules channel)") .define("bot_token", "YOUR_TOKEN_HERE"); CHANNEL_ID = builder .comment("The Discord Channel ID where the rules message is posted") .define("channel_id", "1234567890123456789"); MESSAGE_ID = builder .comment("The 17-20 digit Discord Message ID containing the rules") .define("message_id", "1234567890123456789"); builder.pop(); builder.push("performance"); COOLDOWN_SECONDS = builder .comment("Per-player cooldown for the /rules command in seconds") .defineInRange("cooldown_seconds", 60, 0, 3600); CACHE_MINUTES = builder .comment("How long to cache the rules locally before checking Discord again") .defineInRange("cache_minutes", 30, 1, 1440); builder.pop(); SPEC = builder.build(); } /** * Validates that the message ID is a valid Discord Snowflake format. */ public static boolean isMessageIdValid() { String id = MESSAGE_ID.get(); return StringUtils.isNotBlank(id) && id.matches("^\\d{17,20}$"); } } ``` --- ### 2. DiscordFetcher.java **Uses `CHANNEL_ID` from config instead of hardcoding.** ```java package com.firefrostgaming.rules; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.concurrent.CompletableFuture; public class DiscordFetcher { private static final Logger LOGGER = LoggerFactory.getLogger(DiscordFetcher.class); private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); /** * Asynchronously fetches the rules from Discord. * Returns a CompletableFuture to ensure the main server thread is never blocked. */ public static CompletableFuture fetchRulesAsync() { if (!ServerRulesConfig.isMessageIdValid()) { LOGGER.error("Invalid Discord Message ID in config."); return CompletableFuture.completedFuture(null); } String token = ServerRulesConfig.BOT_TOKEN.get(); String channelId = ServerRulesConfig.CHANNEL_ID.get(); // Now from config String messageId = ServerRulesConfig.MESSAGE_ID.get(); URI uri = URI.create("https://discord.com/api/v10/channels/" + channelId + "/messages/" + messageId); HttpRequest request = HttpRequest.newBuilder() .uri(uri) .header("Authorization", "Bot " + token) .header("Accept", "application/json") .GET() .build(); return CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(response -> { if (response.statusCode() == 200) { JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); return json.get("content").getAsString(); } else { LOGGER.error("Discord API returned status: {}", response.statusCode()); return null; } }) .exceptionally(ex -> { LOGGER.error("Network error while fetching Discord rules", ex); return null; // Signals the caller to use the cache }); } } ``` --- ### 3. RulesCache.java ```java package com.firefrostgaming.rules; import java.time.Instant; public class RulesCache { private static String cachedRules = null; private static Instant lastFetchTime = Instant.MIN; private static final String FALLBACK_RULES = "πŸ”₯ Server Rules\n" + "1. Be respectful to all players.\n" + "2. No griefing or cheating.\n" + "3. Follow staff instructions.\n" + "Please check Discord for the full rules list."; /** * Checks if the cache is still valid based on the configured minutes. */ public static boolean isCacheValid() { if (cachedRules == null) return false; long cacheMinutes = ServerRulesConfig.CACHE_MINUTES.get(); return Instant.now().isBefore(lastFetchTime.plusSeconds(cacheMinutes * 60)); } public static void updateCache(String newRules) { if (newRules != null && !newRules.trim().isEmpty()) { cachedRules = newRules; lastFetchTime = Instant.now(); } } public static String getRules() { return cachedRules != null ? cachedRules : FALLBACK_RULES; } public static void invalidate() { cachedRules = null; lastFetchTime = Instant.MIN; } } ``` --- ### 4. DiscordFormatter.java **Add color palette comment block at top of class.** ```java package com.firefrostgaming.rules; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; /** * FIREFROST COLOR PALETTE: * * Fire Path: * - GOLD (Β§6) - Headers * - YELLOW (Β§e) - Body * - RED (Β§c) - Accents * * Frost Path: * - AQUA (Β§b) - Headers * - DARK_AQUA (Β§3) - Body * - BLUE (Β§9) - Accents * * Arcane/Universal: * - DARK_PURPLE (Β§5) - Headers * - LIGHT_PURPLE (Β§d) - Body * - WHITE (Β§f) - Accents * * Generic: * - GRAY (Β§7) - Bullet points * - DARK_GRAY (Β§8) - Footer/timestamps */ public class DiscordFormatter { /** * Parses Discord text into a formatted Minecraft Component. * Analyzes the text to determine Path color schemes. */ public static MutableComponent formatRules(String rawDiscordText) { String processedText = convertEmojis(rawDiscordText); String lowerText = processedText.toLowerCase(); // Determine theme ChatFormatting headerColor = ChatFormatting.DARK_PURPLE; ChatFormatting bodyColor = ChatFormatting.LIGHT_PURPLE; if (lowerText.contains("fire") || lowerText.contains("[fire]")) { headerColor = ChatFormatting.GOLD; bodyColor = ChatFormatting.YELLOW; } else if (lowerText.contains("frost") || lowerText.contains("[frost]")) { headerColor = ChatFormatting.AQUA; bodyColor = ChatFormatting.DARK_AQUA; } MutableComponent rootComponent = Component.empty(); String[] lines = processedText.split("\n"); for (String line : lines) { MutableComponent lineComponent; if (line.startsWith("**") && line.endsWith("**")) { // Header formatting String cleanLine = line.replace("**", ""); lineComponent = Component.literal(cleanLine) .withStyle(headerColor, ChatFormatting.BOLD); } else if (line.trim().startsWith("-") || line.trim().startsWith("β€’")) { // Bullet point formatting lineComponent = Component.literal(" " + line.trim()) .withStyle(bodyColor); } else { // Standard body text lineComponent = Component.literal(line).withStyle(bodyColor); } rootComponent.append(lineComponent).append(Component.literal("\n")); } return rootComponent; } /** * Converts Discord emojis to text brackets, strips unknown unicode. */ private static String convertEmojis(String text) { if (text == null) return ""; return text.replace("πŸ”₯", "[Fire]") .replace("❄️", "[Frost]") .replace("πŸ’œ", "[Arcane]") // Basic regex to strip remaining standalone emojis .replaceAll("[\\x{1F300}-\\x{1F9FF}]", ""); } } ``` --- ### 5. CooldownManager.java ```java package com.firefrostgaming.rules; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import java.time.Duration; import java.time.Instant; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** * Manages per-player cooldowns for the /rules command. * Thread-safe for async operations. */ public class CooldownManager { // Thread-safe map to store the last time a player used the command private static final ConcurrentHashMap COOLDOWNS = new ConcurrentHashMap<>(); /** * Checks if a player is on cooldown. If not, updates their cooldown timer. * @return true if the player can use the command, false if they are on cooldown. */ public static boolean checkAndUpdateCooldown(ServerPlayer player) { UUID playerId = player.getUUID(); Instant now = Instant.now(); int cooldownSeconds = ServerRulesConfig.COOLDOWN_SECONDS.get(); Instant lastUsed = COOLDOWNS.get(playerId); if (lastUsed != null) { long secondsSinceLastUse = Duration.between(lastUsed, now).getSeconds(); if (secondsSinceLastUse < cooldownSeconds) { long remaining = cooldownSeconds - secondsSinceLastUse; player.sendSystemMessage(Component.literal("Β§cPlease wait " + remaining + " seconds before checking the rules again.")); return false; } } // Update the cooldown timestamp COOLDOWNS.put(playerId, now); return true; } /** * Cleans up memory when a player leaves the server. */ public static void removePlayer(UUID playerId) { COOLDOWNS.remove(playerId); } } ``` --- ### 6. RulesCommand.java ```java package com.firefrostgaming.rules; import com.mojang.brigadier.CommandDispatcher; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.MutableComponent; import net.minecraft.server.level.ServerPlayer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Registers and handles the execution of the /rules command. */ public class RulesCommand { private static final Logger LOGGER = LoggerFactory.getLogger(RulesCommand.class); public static void register(CommandDispatcher dispatcher) { dispatcher.register(Commands.literal("rules") .executes(context -> { CommandSourceStack source = context.getSource(); if (!source.isPlayer()) { source.sendSystemMessage(DiscordFormatter.formatRules(RulesCache.getRules())); return 1; } ServerPlayer player = source.getPlayer(); // 1. Check Cooldown if (!CooldownManager.checkAndUpdateCooldown(player)) { return 0; // Command failed due to cooldown } // 2. Check Cache if (RulesCache.isCacheValid()) { player.sendSystemMessage(DiscordFormatter.formatRules(RulesCache.getRules())); return 1; } // 3. Fetch Async if cache is stale player.sendSystemMessage(net.minecraft.network.chat.Component.literal("Β§7Fetching latest rules...")); DiscordFetcher.fetchRulesAsync().thenAccept(fetchedRules -> { String rulesText; if (fetchedRules != null) { RulesCache.updateCache(fetchedRules); rulesText = fetchedRules; } else { LOGGER.warn("Discord fetch failed. Falling back to cached rules for player {}", player.getName().getString()); rulesText = RulesCache.getRules(); } MutableComponent formattedRules = DiscordFormatter.formatRules(rulesText); // 4. Safely return to the main server thread to send the message source.getServer().execute(() -> { player.sendSystemMessage(formattedRules); }); }); return 1; }) ); } } ``` --- ### 7. ServerRules.java (Main Mod Class) ```java package com.firefrostgaming.rules; import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; import net.neoforged.fml.config.ModConfig; import net.neoforged.fml.event.config.ModConfigEvent; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.RegisterCommandsEvent; import net.neoforged.neoforge.event.entity.player.PlayerEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Main mod class. Handles startup initialization and event routing. */ @Mod("firefrostrules") public class ServerRules { private static final Logger LOGGER = LoggerFactory.getLogger(ServerRules.class); public ServerRules(IEventBus modEventBus, ModContainer modContainer) { // Register TOML Config modContainer.registerConfig(ModConfig.Type.SERVER, ServerRulesConfig.SPEC); // Register configuration reloading listener on the mod bus modEventBus.addListener(this::onConfigReload); // Register server events on the standard NeoForge bus NeoForge.EVENT_BUS.register(this); LOGGER.info("Firefrost Rules Mod Initialized. Waiting for server start..."); } @SubscribeEvent public void onRegisterCommands(RegisterCommandsEvent event) { RulesCommand.register(event.getDispatcher()); LOGGER.info("Registered /rules command."); } @SubscribeEvent public void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { // Clean up memory to prevent memory leaks over time CooldownManager.removePlayer(event.getEntity().getUUID()); } private void onConfigReload(ModConfigEvent.Reloading event) { LOGGER.info("Rules configuration reloaded! Invalidating current cache to force fresh fetch on next command."); RulesCache.invalidate(); } } ``` --- ## πŸš€ **Implementation Steps (Desktop)** ### 1. Create Project Structure ```bash mkdir -p FirefrostRules/src/main/java/com/firefrostgaming/rules mkdir -p FirefrostRules/src/main/resources/META-INF cd FirefrostRules ``` ### 2. Copy Build Files - Copy `gradle.properties` to project root - Copy `build.gradle` to project root - Copy `neoforge.mods.toml` to `src/main/resources/META-INF/` ### 3. Copy Java Classes Copy all 7 Java files to `src/main/java/com/firefrostgaming/rules/`: - ServerRules.java - ServerRulesConfig.java - DiscordFetcher.java - RulesCache.java - DiscordFormatter.java - CooldownManager.java - RulesCommand.java ### 4. Build the Mod ```bash ./gradlew build ``` Output JAR will be in: `build/libs/firefrostrules-1.0.0.jar` ### 5. Deploy to Server ```bash # Copy to Pterodactyl server mods folder scp build/libs/firefrostrules-1.0.0.jar user@server:/path/to/mods/ ``` ### 6. Configure Discord On first run, the mod generates: `config/firefrostrules-server.toml` Edit it: ```toml [discord] bot_token = "YOUR_ARBITER_BOT_TOKEN_HERE" channel_id = "1260574715546701936" # Your #server-rules channel ID message_id = "1234567890123456789" # Message ID from Discord [performance] cooldown_seconds = 60 cache_minutes = 30 ``` **How to get Message ID:** 1. Enable Developer Mode in Discord (User Settings β†’ Advanced β†’ Developer Mode) 2. Right-click the rules message β†’ Copy Message ID 3. Paste into config ### 7. Test ``` /rules ``` Should display rules with Fire/Frost/Arcane colors! --- ## 🎨 **Holly's Editing Workflow** 1. Go to Discord `#server-rules` channel 2. Right-click the message for the server 3. Click "Edit" 4. Update text (use Discord markdown) 5. Save 6. **Next `/rules` call in-game shows new version** (30-min cache delay max) **Emoji support:** - Holly can use πŸ”₯ ❄️ πŸ’œ freely - Mod auto-converts to [Fire] [Frost] [Arcane] - Other emojis are stripped --- ## βš™οΈ **Config Hot-Reload** If Holly updates the Discord message ID or channel: 1. Edit `config/firefrostrules-server.toml` 2. Save file 3. **No server restart needed** β€” mod automatically reloads --- ## πŸ› **Troubleshooting** **"Invalid Discord Message ID"** - Check message ID is 17-20 digits - Enable Developer Mode in Discord to copy IDs **"Discord API returned status: 401"** - Bot token is invalid - Verify token in config **"Discord API returned status: 404"** - Message deleted or wrong ID - Channel ID incorrect **Rules not updating** - Wait 30 minutes (cache expiry) - Or restart server to force cache clear **"Please wait X seconds"** - Player hit cooldown (60 seconds default) - Wait for cooldown to expire --- ## πŸ“Š **Performance Characteristics** - **TPS Impact:** Zero (all HTTP calls async) - **Memory Footprint:** ~2KB (cache + cooldown map) - **Disk I/O:** Zero (config reads on startup/reload only) - **Network:** ~2-48 requests/day to Discord (depending on cache hits) --- ## πŸ”’ **Security Notes** - Bot token stored in plaintext config (standard for Minecraft mods) - Use file permissions to protect config: `chmod 600 config/firefrostrules-server.toml` - Bot only needs "Read Message History" permission in Discord - No player data transmitted to external services --- ## πŸ“ **Credits** **Architecture & Code:** Gemini AI (collaboration partner) **Design & Requirements:** Michael (The Wizard) + Claude (Chronicler #46) **For:** Firefrost Gaming community **Built:** March 28, 2026 **Fire + Frost + Foundation = Where Love Builds Legacy** πŸ’™πŸ”₯❄️