diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 11e8839..ff6bfb8 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -19,6 +19,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
+ submodules: 'recursive'
- name: Checkout workflows repo
uses: actions/checkout@v4
with:
@@ -42,7 +43,7 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Compile the mod
- run: ./gradlew --build-cache --info --stacktrace assemble
+ run: ./gradlew --build-cache --info --stacktrace :core:test assemble
- name: Attach compilation artifacts
uses: actions/upload-artifact@v4
with:
diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml
index ae885f3..a5adbe3 100644
--- a/.github/workflows/release-tags.yml
+++ b/.github/workflows/release-tags.yml
@@ -23,6 +23,7 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 32
+ submodules: 'recursive'
- name: Set up JDK versions
uses: actions/setup-java@v4
@@ -70,7 +71,13 @@ jobs:
-f tag_name="${RELEASE_VERSION}" \
--jq ".body" > "${CHANGELOG_FILE}"
cat "${CHANGELOG_FILE}"
- gh release create "${RELEASE_VERSION}" -F "${CHANGELOG_FILE}" $PRERELEASE ./build/libs/*.jar
+ # Upload only the production reobfuscated mod jar (exclude -dev, -slim, -sources).
+ mapfile -t RELEASE_JARS < <(find build/libs -maxdepth 1 -name '*.jar' ! -name '*-dev*' ! -name '*-slim*' ! -name '*-sources*')
+ if [[ ${#RELEASE_JARS[@]} -eq 0 ]]; then
+ echo "No production jar found in build/libs"
+ exit 1
+ fi
+ gh release create "${RELEASE_VERSION}" -F "${CHANGELOG_FILE}" $PRERELEASE "${RELEASE_JARS[@]}"
shell: bash
continue-on-error: true
env:
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..d1df2d5
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "core"]
+ path = core
+ url = https://github.com/kuba6000/AE2-Web-Integration.git
+ branch = core
diff --git a/build.gradle b/build.gradle
index 65f19d3..21b7dc5 100644
--- a/build.gradle
+++ b/build.gradle
@@ -5,7 +5,7 @@ plugins {
id 'idea'
id 'maven-publish'
alias libs.plugins.modDevGradle
- //alias libs.plugins.shadow
+ alias libs.plugins.shadow
alias libs.plugins.spotless
alias libs.plugins.lombok
id 'com.palantir.git-version' version '4.0.0' apply false // 0.13.0 is the last jvm8 supporting version
@@ -103,6 +103,9 @@ obfuscation {
apply from: "$rootDir/gradle/scripts/jars.gradle"
apply from: "$rootDir/gradle/scripts/moddevgradle.gradle"
+if (findProperty('usesShadowedDependencies')?.toString()?.toBoolean()) {
+ apply from: "$rootDir/gradle/scripts/shadow.gradle"
+}
apply from: "$rootDir/gradle/scripts/repositories.gradle"
apply from: "$rootDir/dependencies.gradle"
apply from: "$rootDir/gradle/scripts/resources.gradle"
diff --git a/core b/core
new file mode 160000
index 0000000..eb7a313
--- /dev/null
+++ b/core
@@ -0,0 +1 @@
+Subproject commit eb7a31329a458cb32dc110581e333a029e4e93e0
diff --git a/dependencies.gradle b/dependencies.gradle
index 526c32f..abb212f 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -17,6 +17,9 @@ dependencies {
require("[${forge.versions.mixinExtras.get()},)")
}
+ // Core submodule (pure Java — no Forge/MC/AE2 references)
+ implementation project(':core')
+
// AE2
modImplementation(forge.ae2)
modCompileOnly(forge.ae2wtlib)
diff --git a/gradle.properties b/gradle.properties
index 882f36d..7001db6 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -12,3 +12,10 @@ mod_license = LGPL-3.0 license
mod_url = https://github.com/kuba6000/AE2-Web-Integration
mod_issue_tracker = https://github.com/kuba6000/AE2-Web-Integration/issues/
maven_group =
+
+# Shadow core submodule into the production jar (see gradle/scripts/shadow.gradle)
+usesShadowedDependencies = true
+# Shadow minimize() resolves runtimeClasspath at configuration time and needs MCP
+# artifacts; that breaks fresh CI before createMinecraftArtifacts runs (MDG).
+minimizeShadowedDependencies = false
+relocateShadowedDependencies = true
diff --git a/gradle/scripts/jars.gradle b/gradle/scripts/jars.gradle
index a9f43f7..74d02fe 100644
--- a/gradle/scripts/jars.gradle
+++ b/gradle/scripts/jars.gradle
@@ -23,7 +23,13 @@ base {
}
afterEvaluate {
- reobfJar.archiveClassifier = ""
+ if (findProperty('usesShadowedDependencies')?.toString()?.toBoolean()) {
+ tasks.named('reobfShadowJar').configure {
+ archiveClassifier = ''
+ }
+ } else {
+ reobfJar.archiveClassifier = ''
+ }
tasks.withType(org.gradle.jvm.tasks.Jar).configureEach {
destinationDirectory = file('build/libs/')
manifest.attributes([
diff --git a/gradle/scripts/shadow.gradle b/gradle/scripts/shadow.gradle
new file mode 100644
index 0000000..9138729
--- /dev/null
+++ b/gradle/scripts/shadow.gradle
@@ -0,0 +1,63 @@
+def shadowRelocationPrefix = 'pl.kuba6000.ae2webintegration.shadow'
+
+configurations {
+ coreShadow {
+ canBeConsumed = false
+ canBeResolved = true
+ }
+}
+
+dependencies {
+ coreShadow(project(':core')) {
+ transitive = true
+ }
+}
+
+tasks.named('shadowJar').configure {
+ dependsOn(':core:jar', 'createMinecraftArtifacts')
+ archiveClassifier = 'dev'
+ configurations = [project.configurations.coreShadow]
+
+ dependencies {
+ exclude(dependency('org.apache.logging.log4j:log4j-api:.*'))
+ exclude(dependency('org.apache.logging.log4j:log4j-core:.*'))
+ }
+
+ if (findProperty('minimizeShadowedDependencies')?.toString()?.toBoolean() != false) {
+ minimize()
+ }
+ if (findProperty('relocateShadowedDependencies')?.toString()?.toBoolean() != false) {
+ relocate('com.google', "${shadowRelocationPrefix}.com.google")
+ relocate('org.apache.commons', "${shadowRelocationPrefix}.org.apache.commons")
+ relocate('commons-io', "${shadowRelocationPrefix}.commons-io")
+ relocate('club.minnced', "${shadowRelocationPrefix}.club.minnced")
+ relocate(
+ 'pl.kuba6000.ae2webintegration.core',
+ "${shadowRelocationPrefix}.pl.kuba6000.ae2webintegration.core")
+ }
+}
+
+tasks.named('jar', Jar).configure {
+ enabled = false
+ finalizedBy(tasks.named('shadowJar'))
+}
+
+obfuscation {
+ reobfuscate(tasks.named('shadowJar'), sourceSets.main) {
+ archiveClassifier = ''
+ }
+}
+
+// MDG still wires reobfJar for the disabled plain jar task — turn it off to avoid clashing with reobfShadowJar.
+tasks.named('reobfJar').configure {
+ enabled = false
+}
+
+configurations.runtimeElements.outgoing.artifacts.clear()
+configurations.apiElements.outgoing.artifacts.clear()
+configurations.runtimeElements.outgoing.artifact(tasks.named('shadowJar'))
+configurations.apiElements.outgoing.artifact(tasks.named('shadowJar'))
+
+configurations.named('shadowRuntimeElements') {
+ outgoing.artifacts.clear()
+}
diff --git a/settings.gradle b/settings.gradle
index c46b849..1d048cb 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -20,3 +20,5 @@ dependencyResolutionManagement {
}
rootProject.name = "${mod_id}"
+
+include ':core'
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/AE2WebIntegration.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/AE2WebIntegration.java
index 6496f1e..fa6279a 100644
--- a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/AE2WebIntegration.java
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/AE2WebIntegration.java
@@ -1,44 +1,72 @@
package pl.kuba6000.ae2webintegration.ae2interface;
+import net.minecraftforge.event.RegisterCommandsEvent;
+import net.minecraftforge.event.server.ServerStartedEvent;
+import net.minecraftforge.event.server.ServerStoppingEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
-import net.minecraftforge.fml.IExtensionPoint;
import net.minecraftforge.fml.ModLoadingContext;
import net.minecraftforge.fml.common.Mod;
+import net.minecraftforge.fml.config.ModConfig;
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
-import net.minecraftforge.network.NetworkConstants;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import pl.kuba6000.ae2webintegration.ae2interface.commands.CommandBuilder;
+import pl.kuba6000.ae2webintegration.ae2interface.config.Config;
import pl.kuba6000.ae2webintegration.ae2interface.implementations.AE;
+import pl.kuba6000.ae2webintegration.ae2interface.platform.Platform;
+import pl.kuba6000.ae2webintegration.ae2interface.proxy.CommonProxy;
+import pl.kuba6000.ae2webintegration.core.CommandBootstrap;
import pl.kuba6000.ae2webintegration.core.api.IAEWebInterface;
@Mod(value = AE2WebIntegration.MODID)
@Mod.EventBusSubscriber(modid = AE2WebIntegration.MODID)
public class AE2WebIntegration {
- public static final String MODID = "ae2webintegration_interface";
+ public static final String MODID = "ae2webintegration";
public static final Logger LOG = LogManager.getLogger(MODID);
+ private static final CommonProxy PROXY = new CommonProxy();
+
public AE2WebIntegration() {
+ Platform platform = new Platform();
+ String version = ModLoadingContext.get()
+ .getActiveContainer()
+ .getModInfo()
+ .getVersion()
+ .toString();
+
+ // Register config before anything that depends on it
ModLoadingContext.get()
- .registerExtensionPoint(
- IExtensionPoint.DisplayTest.class,
- () -> new IExtensionPoint.DisplayTest(() -> NetworkConstants.IGNORESERVERONLY, (a, b) -> true));
- // SecurityCache.registerOpPlayer(
- // IAEWebInterface.getInstance()
- // .getAEWebGameProfile());
+ .registerConfig(ModConfig.Type.COMMON, Config.SPEC, "ae2webintegration/ae2webintegration.toml");
+
+ // Delegate remaining init to the proxy
+ PROXY.preInit(platform, version);
}
@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.MOD)
- private static class eventHandler {
+ private static class ModEventHandler {
@SubscribeEvent
public static void commonSetup(FMLCommonSetupEvent event) {
- // This is where you can do common setup tasks
IAEWebInterface.getInstance()
.initAEInterface(AE.instance);
}
}
+ @SubscribeEvent
+ public static void commandsRegister(RegisterCommandsEvent event) {
+ CommandBootstrap.init(new CommandBuilder(event.getDispatcher()));
+ }
+
+ @SubscribeEvent
+ public static void serverStarted(ServerStartedEvent event) {
+ PROXY.onServerStarted();
+ }
+
+ @SubscribeEvent
+ public static void serverStopping(ServerStoppingEvent event) {
+ PROXY.onServerStopping();
+ }
}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/core/FMLEventHandler.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/FMLEventHandler.java
similarity index 55%
rename from src/main/java/pl/kuba6000/ae2webintegration/core/FMLEventHandler.java
rename to src/main/java/pl/kuba6000/ae2webintegration/ae2interface/FMLEventHandler.java
index b60e804..a71ed9f 100644
--- a/src/main/java/pl/kuba6000/ae2webintegration/core/FMLEventHandler.java
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/FMLEventHandler.java
@@ -1,9 +1,5 @@
-package pl.kuba6000.ae2webintegration.core;
+package pl.kuba6000.ae2webintegration.ae2interface;
-import static pl.kuba6000.ae2webintegration.core.AE2WebIntegration.MODID;
-
-import net.minecraft.ChatFormatting;
-import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.TickEvent;
@@ -11,10 +7,12 @@
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
+import pl.kuba6000.ae2webintegration.core.AE2Controller;
+import pl.kuba6000.ae2webintegration.core.UpdateNotifier;
import pl.kuba6000.ae2webintegration.core.ae2request.sync.ISyncedRequest;
-import pl.kuba6000.ae2webintegration.core.utils.VersionChecker;
+import pl.kuba6000.ae2webintegration.core.api.PlayerIdentity;
-@Mod.EventBusSubscriber(modid = MODID)
+@Mod.EventBusSubscriber(modid = AE2WebIntegration.MODID)
public class FMLEventHandler {
@SubscribeEvent
@@ -32,13 +30,11 @@ public static void tick(TickEvent.ServerTickEvent event) {
@SubscribeEvent
public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
Player player = event.getEntity();
- if (!(player instanceof ServerPlayer)) return;
- if (Config.INSTANCE.CHECK_FOR_UPDATES.get() && VersionChecker.isOutdated() && player.hasPermissions(4)) {
- player.sendSystemMessage(
- Component.literal(
- ChatFormatting.GREEN.toString() + ChatFormatting.BOLD
- + "----> AE2WebIntegration -> New version detected! Consider updating at https://github.com/kuba6000/AE2-Web-Integration/releases/latest"));
- }
- }
+ if (!(player instanceof ServerPlayer serverPlayer)) return;
+ if (!serverPlayer.hasPermissions(4)) return;
+ PlayerIdentity identity = new PlayerIdentity(serverPlayer.getUUID(), serverPlayer.getScoreboardName());
+ PlayerMessenger messenger = new PlayerMessenger();
+ UpdateNotifier.notifyPlayerIfOutdated(messenger, identity);
+ }
}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/PlayerMessenger.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/PlayerMessenger.java
new file mode 100644
index 0000000..c72ba87
--- /dev/null
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/PlayerMessenger.java
@@ -0,0 +1,25 @@
+package pl.kuba6000.ae2webintegration.ae2interface;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraftforge.server.ServerLifecycleHooks;
+
+import pl.kuba6000.ae2webintegration.core.api.IPlayerMessenger;
+import pl.kuba6000.ae2webintegration.core.api.PlayerIdentity;
+
+public class PlayerMessenger implements IPlayerMessenger {
+
+ @Override
+ public void sendMessage(PlayerIdentity player, String message) {
+ if (ServerLifecycleHooks.getCurrentServer() == null) return;
+ ServerPlayer serverPlayer = ServerLifecycleHooks.getCurrentServer()
+ .getPlayerList()
+ .getPlayer(player.uuid);
+ if (serverPlayer != null) {
+ serverPlayer.sendSystemMessage(
+ Component.literal(message)
+ .withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD));
+ }
+ }
+}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/commands/CommandBuilder.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/commands/CommandBuilder.java
new file mode 100644
index 0000000..6d11c49
--- /dev/null
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/commands/CommandBuilder.java
@@ -0,0 +1,140 @@
+package pl.kuba6000.ae2webintegration.ae2interface.commands;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.commands.Commands;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.arguments.StringArgumentType;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.builder.RequiredArgumentBuilder;
+
+import pl.kuba6000.ae2webintegration.core.api.ICommandBuilder;
+import pl.kuba6000.ae2webintegration.core.api.ICommandContext;
+
+/**
+ * {@link ICommandBuilder} implementation that builds a Brigadier command tree
+ * and registers it with the {@link CommandDispatcher}.
+ *
+ * The tree is stored in simple data nodes during the fluent construction phase
+ * (no Brigadier objects involved). At {@link #register()} time the data tree
+ * is walked depth-first and the full Brigadier tree is built from scratch,
+ * ensuring every subtree is complete before it's attached via {@code .then()}.
+ */
+public class CommandBuilder implements ICommandBuilder {
+
+ private static class CommandNode {
+
+ final String name;
+ final int permission;
+ final boolean isArgument;
+ final List children = new ArrayList<>();
+ Consumer handler;
+
+ CommandNode(String name, int permission, boolean isArgument) {
+ this.name = name;
+ this.permission = permission;
+ this.isArgument = isArgument;
+ }
+ }
+
+ private final CommandDispatcher dispatcher;
+ private final ICommandBuilder fluentParent;
+ private final boolean isRoot;
+ private final CommandNode currentNode;
+ private final List rootNodes;
+
+ public CommandBuilder(CommandDispatcher dispatcher) {
+ this.dispatcher = dispatcher;
+ this.fluentParent = null;
+ this.isRoot = true;
+ this.currentNode = null;
+ this.rootNodes = new ArrayList<>();
+ }
+
+ private CommandBuilder(ICommandBuilder fluentParent, CommandNode currentNode, List rootNodes) {
+ this.dispatcher = null;
+ this.fluentParent = fluentParent;
+ this.isRoot = false;
+ this.currentNode = currentNode;
+ this.rootNodes = rootNodes;
+ }
+
+ @Override
+ public ICommandBuilder literal(String name, int permission) {
+ CommandNode child = new CommandNode(name, permission, false);
+ if (isRoot) {
+ rootNodes.add(child);
+ } else if (currentNode != null) {
+ currentNode.children.add(child);
+ }
+ return new CommandBuilder(this, child, rootNodes);
+ }
+
+ @Override
+ public ICommandBuilder argument(String name) {
+ CommandNode child = new CommandNode(name, 0, true);
+ if (currentNode != null) {
+ currentNode.children.add(child);
+ }
+ return new CommandBuilder(this, child, rootNodes);
+ }
+
+ @Override
+ public ICommandBuilder executes(Consumer handler) {
+ if (currentNode != null) {
+ currentNode.handler = handler;
+ }
+ return fluentParent;
+ }
+
+ @Override
+ public void register() {
+ for (CommandNode root : rootNodes) {
+ dispatcher.register(buildLiteral(root));
+ }
+ }
+
+ private static LiteralArgumentBuilder buildLiteral(CommandNode node) {
+ LiteralArgumentBuilder lit = Commands.literal(node.name)
+ .requires(s -> s.hasPermission(node.permission));
+
+ if (node.handler != null) {
+ lit.executes(ctx -> {
+ node.handler.accept(new CommandContext(ctx));
+ return 1;
+ });
+ }
+
+ for (CommandNode child : node.children) {
+ lit.then(buildChild(child));
+ }
+
+ return lit;
+ }
+
+ private static com.mojang.brigadier.builder.ArgumentBuilder buildChild(CommandNode node) {
+ if (node.isArgument) {
+ RequiredArgumentBuilder arg = Commands
+ .argument(node.name, StringArgumentType.word());
+
+ if (node.handler != null) {
+ arg.executes(ctx -> {
+ node.handler.accept(new CommandContext(ctx));
+ return 1;
+ });
+ }
+
+ for (CommandNode child : node.children) {
+ arg.then(buildChild(child));
+ }
+
+ return arg;
+ } else {
+ return buildLiteral(node);
+ }
+ }
+}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/commands/CommandContext.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/commands/CommandContext.java
new file mode 100644
index 0000000..7ce0f59
--- /dev/null
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/commands/CommandContext.java
@@ -0,0 +1,75 @@
+package pl.kuba6000.ae2webintegration.ae2interface.commands;
+
+import java.util.UUID;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+
+import pl.kuba6000.ae2webintegration.core.api.ICommandContext;
+
+/**
+ * {@link ICommandContext} implementation wrapping a Brigadier
+ * {@link com.mojang.brigadier.context.CommandContext}.
+ */
+public class CommandContext implements ICommandContext {
+
+ private final com.mojang.brigadier.context.CommandContext context;
+ private final String[] args;
+
+ public CommandContext(com.mojang.brigadier.context.CommandContext context) {
+ this.context = context;
+ this.args = parseArgs(context.getInput());
+ }
+
+ private static String[] parseArgs(String input) {
+ String[] parts = input.split(" ");
+ if (parts.length <= 1) {
+ return new String[0];
+ }
+ String[] result = new String[parts.length - 1];
+ System.arraycopy(parts, 1, result, 0, parts.length - 1);
+ return result;
+ }
+
+ @Override
+ public String[] getArgs() {
+ return args;
+ }
+
+ @Override
+ public UUID getPlayerUUID() {
+ ServerPlayer player = context.getSource()
+ .getPlayer();
+ return player != null ? player.getUUID() : null;
+ }
+
+ @Override
+ public boolean hasPermission(int level) {
+ return context.getSource()
+ .hasPermission(level);
+ }
+
+ @Override
+ public void sendMessage(String text) {
+ context.getSource()
+ .sendSuccess(
+ () -> Component.literal(text)
+ .withStyle(ChatFormatting.GREEN),
+ false);
+ }
+
+ @Override
+ public void sendError(String text) {
+ context.getSource()
+ .sendFailure(
+ Component.literal(text)
+ .withStyle(ChatFormatting.RED));
+ }
+
+ @Override
+ public Runnable getReloader() {
+ return () -> {};
+ }
+}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/Config.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/Config.java
new file mode 100644
index 0000000..9651145
--- /dev/null
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/Config.java
@@ -0,0 +1,52 @@
+package pl.kuba6000.ae2webintegration.ae2interface.config;
+
+import net.minecraftforge.common.ForgeConfigSpec;
+import net.minecraftforge.eventbus.api.SubscribeEvent;
+import net.minecraftforge.fml.common.Mod;
+import net.minecraftforge.fml.event.config.ModConfigEvent;
+
+import pl.kuba6000.ae2webintegration.ae2interface.AE2WebIntegration;
+import pl.kuba6000.ae2webintegration.core.AE2Controller;
+import pl.kuba6000.ae2webintegration.core.ConfigBootstrap;
+
+/**
+ * Forge 1.20.1 config wiring. This class does NOT define what config keys
+ * exist — that is owned by {@link ConfigBootstrap}. Instead it:
+ *
+ * - Creates a {@link ForgeConfigSpec.Builder}
+ * - Wraps it in a {@link ConfigBuilder}
+ * - Passes the wrapper to {@link ConfigBootstrap#init} so core defines all keys
+ * - Builds the {@link ForgeConfigSpec} and exposes it as {@link #SPEC}
+ *
+ *
+ * Because {@link ConfigValue} reads live from the Forge config system on
+ * every {@code get()}, no explicit value-copying step is needed — values are
+ * always current after Forge fires its config events.
+ */
+@Mod.EventBusSubscriber(modid = AE2WebIntegration.MODID, bus = Mod.EventBusSubscriber.Bus.MOD)
+public class Config {
+
+ public static final ForgeConfigSpec SPEC;
+
+ static {
+ ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
+ ConfigBootstrap.init(new ConfigBuilder(builder));
+ SPEC = builder.build();
+ }
+
+ private Config() {}
+
+ // --- Event handlers ---
+
+ @SubscribeEvent
+ public static void onConfigLoading(ModConfigEvent.Loading event) {
+ AE2WebIntegration.LOG.info("Config loaded");
+ }
+
+ @SubscribeEvent
+ public static void onConfigReloading(ModConfigEvent.Reloading event) {
+ AE2Controller.stopHTTPServer();
+ AE2Controller.startHTTPServer();
+ AE2WebIntegration.LOG.info("Config reloaded, web server restarted");
+ }
+}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/ConfigBuilder.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/ConfigBuilder.java
new file mode 100644
index 0000000..4321e7c
--- /dev/null
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/ConfigBuilder.java
@@ -0,0 +1,43 @@
+package pl.kuba6000.ae2webintegration.ae2interface.config;
+
+import net.minecraftforge.common.ForgeConfigSpec;
+
+import pl.kuba6000.ae2webintegration.core.api.IConfigBuilder;
+import pl.kuba6000.ae2webintegration.core.api.IConfigValue;
+
+/**
+ * {@link IConfigBuilder} implementation wrapping Forge 1.20.1's
+ * {@link ForgeConfigSpec.Builder}.
+ */
+public class ConfigBuilder implements IConfigBuilder {
+
+ private final ForgeConfigSpec.Builder builder;
+
+ public ConfigBuilder(ForgeConfigSpec.Builder builder) {
+ this.builder = builder;
+ }
+
+ @Override
+ public IConfigValue defineInt(String key, int defaultValue, int min, int max, String comment) {
+ return new ConfigValue<>(
+ builder.comment(comment)
+ .defineInRange(key, defaultValue, min, max),
+ defaultValue);
+ }
+
+ @Override
+ public IConfigValue defineString(String key, String defaultValue, String comment) {
+ return new ConfigValue<>(
+ builder.comment(comment)
+ .define(key, defaultValue),
+ defaultValue);
+ }
+
+ @Override
+ public IConfigValue defineBoolean(String key, boolean defaultValue, String comment) {
+ return new ConfigValue<>(
+ builder.comment(comment)
+ .define(key, defaultValue),
+ defaultValue);
+ }
+}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/ConfigValue.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/ConfigValue.java
new file mode 100644
index 0000000..3278237
--- /dev/null
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/config/ConfigValue.java
@@ -0,0 +1,36 @@
+package pl.kuba6000.ae2webintegration.ae2interface.config;
+
+import net.minecraftforge.common.ForgeConfigSpec;
+
+import pl.kuba6000.ae2webintegration.core.api.IConfigValue;
+
+/**
+ * Wraps a Forge {@link ForgeConfigSpec.ConfigValue} inside the
+ * platform-agnostic {@link IConfigValue} interface.
+ *
+ * Until {@code ModConfigEvent.Loading} fires, {@code ConfigValue.get()}
+ * throws {@link IllegalStateException}. This wrapper catches that and returns
+ * the hardcoded default, allowing code that runs before the config
+ * event to read configuration values without crashing.
+ *
+ * @param the value type (Integer, String, Boolean)
+ */
+public class ConfigValue implements IConfigValue {
+
+ private final ForgeConfigSpec.ConfigValue configValue;
+ private final T defaultValue;
+
+ public ConfigValue(ForgeConfigSpec.ConfigValue configValue, T defaultValue) {
+ this.configValue = configValue;
+ this.defaultValue = defaultValue;
+ }
+
+ @Override
+ public T get() {
+ try {
+ return configValue.get();
+ } catch (IllegalStateException e) {
+ return defaultValue;
+ }
+ }
+}
diff --git a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/mixins/AE2/CraftingCPULogicMixin.java b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/mixins/AE2/CraftingCPULogicMixin.java
index 55539f4..1c1f880 100644
--- a/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/mixins/AE2/CraftingCPULogicMixin.java
+++ b/src/main/java/pl/kuba6000/ae2webintegration/ae2interface/mixins/AE2/CraftingCPULogicMixin.java
@@ -85,11 +85,11 @@ public class CraftingCPULogicMixin implements ICraftingCPULogicAccessor {
private boolean ae2webintegration$pushPattern(ICraftingProvider medium, IPatternDetails details, KeyCounter[] ic) {
if (medium.pushPattern(details, ic)) {
IGridNode viewable = null;
- Map mediumToViewable = ((IAECraftingGrid) cluster.getGrid()
+ Map