Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches: [main, feature/logistics-foundation, feature/durable-shipment-journal]
pull_request:
workflow_dispatch:
inputs:
is_Mythic_version:
description: "Build the Mythic variant (true or false)"
required: true
type: boolean
default: false

# Avoid duplicate runs for rapid pushes to the same branch/PR.
concurrency:
Expand All @@ -17,6 +23,11 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
# PRs/pushes verify both outputs. A manual dispatch builds the selected variant only.
matrix:
is_Mythic_version: ${{ github.event_name == 'workflow_dispatch' && fromJSON(format('[{0}]', inputs.is_Mythic_version)) || fromJSON('[false,true]') }}
steps:
- name: Check out repository
uses: actions/checkout@v4
Expand All @@ -36,12 +47,12 @@ jobs:
validate-wrappers: false

- name: Build mod
run: gradle --no-daemon build
run: gradle --no-daemon -Pis_Mythic_version=${{ matrix.is_Mythic_version }} build

- name: Upload mod JAR
uses: actions/upload-artifact@v4
with:
name: icreate-order-networking-${{ github.sha }}
name: icreate-order-networking-${{ github.sha }}-${{ matrix.is_Mythic_version }}
path: |
build/libs/*.jar
!build/libs/*-sources.jar
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,11 @@ The Order Terminal Display Link reports active orders and outstanding item quant


The Order Terminal Display Link has regression tests for active-order counting, outstanding quantities, owner scoping, and unclaimed terminals.

## Mythic variant and Discord bridge

Build the optional server-only Mythic integration with `gradle --no-daemon -Pis_Mythic_version=true build`; the default `false` build excludes the Mythic integration package. The selected value is recorded in the JAR manifest as `Is-Mythic-Version`.

The Mythic dedicated server can post server lifecycle and player join/leave events to a Discord webhook. Missile launch, impact, and interception event hooks are included for the missile subsystem to call when those systems are connected. Server-side configuration and FTB team display-name mappings are documented in [docs/MYTHIC_DISCORD_INTEGRATION.md](docs/MYTHIC_DISCORD_INTEGRATION.md).

The current bridge uses a server-only Discord webhook and local FTB team mapping; it does not yet connect the bot's `/mc-link` or team-sync approval queue, which needs a matching authenticated API in the bot process.
23 changes: 21 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ plugins {
version = mod_version
group = mod_group

// Allow callers to select the Mythic build variant with -Pis_Mythic_version=true|false.
// The default is false, including for local builds that do not pass the property.
def mythicVersionRaw = providers.gradleProperty('is_Mythic_version').getOrElse('false')
if (!(mythicVersionRaw in ['true', 'false'])) {
throw new GradleException("is_Mythic_version must be 'true' or 'false' (received: '${mythicVersionRaw}')")
}
def isMythicVersion = mythicVersionRaw.toBoolean()
logger.lifecycle("Building Mythic variant: ${isMythicVersion}")

repositories {
maven { url = "https://maven.createmod.net" }
maven { url = "https://maven.ithundxr.dev/snapshots" }
Expand All @@ -30,14 +39,24 @@ base {

java.toolchain.languageVersion = JavaLanguageVersion.of(21)

// The NeoForge ModDev plugin adds Minecraft/NeoForge dependencies to the main
// source set. Make that same compile/runtime classpath available to JUnit tests.
// The public build contains no Mythic/Discord integration classes. The Mythic
// profile compiles the optional server-only package into its JAR.
sourceSets {
test {
compileClasspath += sourceSets.main.compileClasspath
runtimeClasspath += sourceSets.main.runtimeClasspath
}
}
if (!isMythicVersion) {
sourceSets.main.java.exclude 'com/innygr/icreateordernetworking/mythic/**'
}

// Expose the selected variant in the built JAR so build artifacts can be identified.
tasks.withType(Jar).configureEach {
manifest {
attributes('Is-Mythic-Version': isMythicVersion.toString())
}
}

tasks.withType(Test).configureEach {
useJUnitPlatform()
Expand Down
46 changes: 46 additions & 0 deletions docs/MYTHIC_DISCORD_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Mythic server integration

The Mythic build is selected with `-Pis_Mythic_version=true`. The default/public build uses `false` and excludes the entire `com.innygr.icreateordernetworking.mythic` package, including its Discord HTTP client and team-mapping loader. No Discord token or webhook is bundled in the JAR or sent to clients.

## Build commands

```sh
gradle --no-daemon -Pis_Mythic_version=true build
gradle --no-daemon -Pis_Mythic_version=false build
```

The JAR manifest records `Is-Mythic-Version: true|false`.

## Discord event notifications

On the first Mythic server start, the integration creates `config/icreate-order-networking-mythic.json`:

```json
{
"enabled": false,
"serverName": "Minecraft server",
"discordWebhookUrl": ""
}
```

Set `enabled` to `true` and set `discordWebhookUrl` to a Discord HTTPS webhook URL in the dedicated server's config directory. Never commit this file or share the webhook URL. Events are sent asynchronously, HTTP failures do not block the server tick, and user/target text is sanitized to prevent mass mentions. Player join/leave and server lifecycle events are wired now.

The missile subsystem can publish launch, impact, and interception notifications through `MythicIntegration.missileLaunched`, `missileImpacted`, and `missileIntercepted`. These are integration hooks for the missile assembly/flight/defence code; they are not automatically emitted by a full missile flight system yet.

## FTB Teams mapping

The integration creates `config/icreate-order-networking-mythic-teams.json` with schema version 1. Add a mapping for each FTB team ID:

```json
{
"schemaVersion": 1,
"teams": {
"your-ftb-team-id": {
"name": "Your team name",
"discordRoleId": "optional-discord-role-id"
}
}
}
```

This is a server-side, read-only mapping used to give missile events a stable team display name. It does not modify FTB Teams membership or automatically synchronize Discord bot account-link requests. The current Discord notification transport is a webhook; connecting the bot's account-link/approval queue requires a matching authenticated API endpoint in the bot process.
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
org.gradle.jvmargs=-Xmx3G
org.gradle.daemon=false

# Override from the command line with -Pis_Mythic_version=true or false.
is_Mythic_version=false

minecraft_version=1.21.1
minecraft_version_range=[1.21.1,1.22)
neo_forge_version=21.1.251
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import com.simibubi.create.api.behaviour.display.DisplaySource;
import com.simibubi.create.api.registry.CreateRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
Expand All @@ -30,11 +31,16 @@
import net.neoforged.neoforge.registries.RegisterEvent;
import net.neoforged.neoforge.event.server.ServerStartedEvent;
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Main entry point for iCreate: Order Networking. */
@Mod(ICreateOrderNetworking.MOD_ID)
public final class ICreateOrderNetworking {
public static final String MOD_ID = "icreate_order_networking";
private static final Logger LOGGER = LoggerFactory.getLogger(ICreateOrderNetworking.class);
private static final String MYTHIC_INTEGRATION_CLASS =
"com.innygr.icreateordernetworking.mythic.MythicIntegration";

private final RedstoneRequesterOrderBridge requesterOrderBridge = new RedstoneRequesterOrderBridge();
private final CreateStockLinkEndpointBridge stockLinkEndpointBridge = new CreateStockLinkEndpointBridge();
Expand Down Expand Up @@ -64,15 +70,32 @@ private void onServerStarted(ServerStartedEvent event) {
// Retry durable payouts only after a destination receipt was already recorded.
// Transport adapters still own recovery for shipments awaiting physical reconciliation.
DurableShipmentService.recoverPendingPayouts(event.getServer().overworld());
invokeMythicLifecycleHook("onServerStarted", event.getServer());
}

private void onServerStopped(ServerStoppedEvent event) {
invokeMythicLifecycleHook("onServerStopped", event.getServer());
requesterOrderBridge.clear();
stockLinkEndpointBridge.clear();
AutomatedDeliveryRegistry.clear();
FactoryEndpointRegistry.clear();
}

/**
* Load the optional integration reflectively so the regular public build
* can omit the entire Mythic package without a compile-time dependency.
*/
private static void invokeMythicLifecycleHook(String methodName, MinecraftServer server) {
try {
Class<?> integration = Class.forName(MYTHIC_INTEGRATION_CLASS);
integration.getMethod(methodName, MinecraftServer.class).invoke(null, server);
} catch (ClassNotFoundException ignored) {
// Expected in the regular public build.
} catch (ReflectiveOperationException exception) {
LOGGER.error("Failed to invoke optional Mythic lifecycle hook '{}'.", methodName, exception);
}
}

private void registerCapabilities(RegisterCapabilitiesEvent event) {
event.registerBlockEntity(Capabilities.ItemHandler.BLOCK,
ModBlockEntities.TERMINAL_LOGGER.get(),
Expand Down Expand Up @@ -104,5 +127,4 @@ private void registerDisplaySourceBindings(FMLCommonSetupEvent event) {
OrderTerminalDisplaySource.INSTANCE);
});
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.innygr.icreateordernetworking.mythic;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.neoforged.fml.loading.FMLPaths;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
* Server-only Discord notifier for the optional Mythic build.
*
* A Discord webhook is used instead of a bot token: it is configured only on
* the dedicated server and is never sent to clients or embedded in the JAR.
*/
public final class DiscordBridge {
private static final Logger LOGGER = LoggerFactory.getLogger("iCreateOrderNetworking/MythicDiscord");
private static final Path CONFIG_PATH = FMLPaths.CONFIGDIR.get().resolve("icreate-order-networking-mythic.json");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "icreate-mythic-discord");
thread.setDaemon(true);
return thread;
});

private static volatile String webhookUrl = "";
private static volatile String serverName = "Minecraft server";
private static volatile boolean enabled;
private static volatile boolean stopped;

private DiscordBridge() {}

static void configure(boolean isEnabled, String webhook, String configuredServerName) {
enabled = isEnabled;
webhookUrl = webhook == null ? "" : webhook.strip();
serverName = configuredServerName == null || configuredServerName.isBlank()
? "Minecraft server" : configuredServerName.strip();
stopped = false;
}

public static boolean isConfigured() {
return enabled && isHttpsWebhook(webhookUrl);
}

public static void sendEvent(String eventType, String details) {
if (stopped || !isConfigured()) return;
String safeType = sanitize(eventType, 80);
String safeDetails = sanitize(details, 1500);
JsonObject payload = new JsonObject();
payload.addProperty("content", "**[" + serverName + "] " + safeType + "**\n" + safeDetails);
JsonObject allowedMentions = new JsonObject();
allowedMentions.add("parse", new com.google.gson.JsonArray());
payload.add("allowed_mentions", allowedMentions);

HttpRequest request;
try {
request = HttpRequest.newBuilder(URI.create(webhookUrl))
.timeout(Duration.ofSeconds(8))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(payload.toString(), StandardCharsets.UTF_8))
.build();
} catch (IllegalArgumentException exception) {
LOGGER.warn("Mythic Discord webhook URL is invalid; event was not sent.");
return;
}

EXECUTOR.submit(() -> {
try {
HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
LOGGER.warn("Mythic Discord webhook returned HTTP {}; event was not delivered.", response.statusCode());
}
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
} catch (IOException | RuntimeException exception) {
LOGGER.warn("Unable to deliver Mythic Discord event: {}", exception.getMessage());
}
});
}

static void shutdown() {
stopped = true;
}

private static boolean isHttpsWebhook(String value) {
if (value == null || value.isBlank()) return false;
try {
URI uri = URI.create(value);
return "https".equalsIgnoreCase(uri.getScheme())
&& uri.getHost() != null
&& uri.getHost().equalsIgnoreCase("discord.com")
&& uri.getPath() != null
&& uri.getPath().startsWith("/api/webhooks/");
} catch (IllegalArgumentException exception) {
return false;
}
}

private static String sanitize(String value, int maxLength) {
if (value == null) return "n/a";
String normalized = value.replace("@everyone", "@\u200beveryone")
.replace("@here", "@\u200bhere")
.replace("\r", " ")
.strip();
return normalized.length() > maxLength ? normalized.substring(0, maxLength) : normalized;
}
}
Loading
Loading