Skip to content

Commit a9cf2fa

Browse files
authored
Merge pull request #19 from Innygr/build/mythic-version-flag
Add Mythic build profile and Discord integration
2 parents 5531858 + 0686c34 commit a9cf2fa

9 files changed

Lines changed: 462 additions & 5 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ on:
55
branches: [main, feature/logistics-foundation, feature/durable-shipment-journal]
66
pull_request:
77
workflow_dispatch:
8+
inputs:
9+
is_Mythic_version:
10+
description: "Build the Mythic variant (true or false)"
11+
required: true
12+
type: boolean
13+
default: false
814

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

3849
- name: Build mod
39-
run: gradle --no-daemon build
50+
run: gradle --no-daemon -Pis_Mythic_version=${{ matrix.is_Mythic_version }} build
4051

4152
- name: Upload mod JAR
4253
uses: actions/upload-artifact@v4
4354
with:
44-
name: icreate-order-networking-${{ github.sha }}
55+
name: icreate-order-networking-${{ github.sha }}-${{ matrix.is_Mythic_version }}
4556
path: |
4657
build/libs/*.jar
4758
!build/libs/*-sources.jar

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,11 @@ The Order Terminal Display Link reports active orders and outstanding item quant
6161

6262

6363
The Order Terminal Display Link has regression tests for active-order counting, outstanding quantities, owner scoping, and unclaimed terminals.
64+
65+
## Mythic variant and Discord bridge
66+
67+
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`.
68+
69+
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).
70+
71+
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.

‎build.gradle‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ plugins {
55
version = mod_version
66
group = mod_group
77

8+
// Allow callers to select the Mythic build variant with -Pis_Mythic_version=true|false.
9+
// The default is false, including for local builds that do not pass the property.
10+
def mythicVersionRaw = providers.gradleProperty('is_Mythic_version').getOrElse('false')
11+
if (!(mythicVersionRaw in ['true', 'false'])) {
12+
throw new GradleException("is_Mythic_version must be 'true' or 'false' (received: '${mythicVersionRaw}')")
13+
}
14+
def isMythicVersion = mythicVersionRaw.toBoolean()
15+
logger.lifecycle("Building Mythic variant: ${isMythicVersion}")
16+
817
repositories {
918
maven { url = "https://maven.createmod.net" }
1019
maven { url = "https://maven.ithundxr.dev/snapshots" }
@@ -30,14 +39,24 @@ base {
3039

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

33-
// The NeoForge ModDev plugin adds Minecraft/NeoForge dependencies to the main
34-
// source set. Make that same compile/runtime classpath available to JUnit tests.
42+
// The public build contains no Mythic/Discord integration classes. The Mythic
43+
// profile compiles the optional server-only package into its JAR.
3544
sourceSets {
3645
test {
3746
compileClasspath += sourceSets.main.compileClasspath
3847
runtimeClasspath += sourceSets.main.runtimeClasspath
3948
}
4049
}
50+
if (!isMythicVersion) {
51+
sourceSets.main.java.exclude 'com/innygr/icreateordernetworking/mythic/**'
52+
}
53+
54+
// Expose the selected variant in the built JAR so build artifacts can be identified.
55+
tasks.withType(Jar).configureEach {
56+
manifest {
57+
attributes('Is-Mythic-Version': isMythicVersion.toString())
58+
}
59+
}
4160

4261
tasks.withType(Test).configureEach {
4362
useJUnitPlatform()

‎docs/MYTHIC_DISCORD_INTEGRATION.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Mythic server integration
2+
3+
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.
4+
5+
## Build commands
6+
7+
```sh
8+
gradle --no-daemon -Pis_Mythic_version=true build
9+
gradle --no-daemon -Pis_Mythic_version=false build
10+
```
11+
12+
The JAR manifest records `Is-Mythic-Version: true|false`.
13+
14+
## Discord event notifications
15+
16+
On the first Mythic server start, the integration creates `config/icreate-order-networking-mythic.json`:
17+
18+
```json
19+
{
20+
"enabled": false,
21+
"serverName": "Minecraft server",
22+
"discordWebhookUrl": ""
23+
}
24+
```
25+
26+
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.
27+
28+
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.
29+
30+
## FTB Teams mapping
31+
32+
The integration creates `config/icreate-order-networking-mythic-teams.json` with schema version 1. Add a mapping for each FTB team ID:
33+
34+
```json
35+
{
36+
"schemaVersion": 1,
37+
"teams": {
38+
"your-ftb-team-id": {
39+
"name": "Your team name",
40+
"discordRoleId": "optional-discord-role-id"
41+
}
42+
}
43+
}
44+
```
45+
46+
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.

‎gradle.properties‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
org.gradle.jvmargs=-Xmx3G
22
org.gradle.daemon=false
33

4+
# Override from the command line with -Pis_Mythic_version=true or false.
5+
is_Mythic_version=false
6+
47
minecraft_version=1.21.1
58
minecraft_version_range=[1.21.1,1.22)
69
neo_forge_version=21.1.251

‎src/main/java/com/innygr/icreateordernetworking/ICreateOrderNetworking.java‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.simibubi.create.api.behaviour.display.DisplaySource;
2222
import com.simibubi.create.api.registry.CreateRegistries;
2323
import net.minecraft.resources.ResourceLocation;
24+
import net.minecraft.server.MinecraftServer;
2425
import net.neoforged.bus.api.IEventBus;
2526
import net.neoforged.fml.common.Mod;
2627
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
@@ -30,11 +31,16 @@
3031
import net.neoforged.neoforge.registries.RegisterEvent;
3132
import net.neoforged.neoforge.event.server.ServerStartedEvent;
3233
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
34+
import org.slf4j.Logger;
35+
import org.slf4j.LoggerFactory;
3336

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

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

6976
private void onServerStopped(ServerStoppedEvent event) {
77+
invokeMythicLifecycleHook("onServerStopped", event.getServer());
7078
requesterOrderBridge.clear();
7179
stockLinkEndpointBridge.clear();
7280
AutomatedDeliveryRegistry.clear();
7381
FactoryEndpointRegistry.clear();
7482
}
7583

84+
/**
85+
* Load the optional integration reflectively so the regular public build
86+
* can omit the entire Mythic package without a compile-time dependency.
87+
*/
88+
private static void invokeMythicLifecycleHook(String methodName, MinecraftServer server) {
89+
try {
90+
Class<?> integration = Class.forName(MYTHIC_INTEGRATION_CLASS);
91+
integration.getMethod(methodName, MinecraftServer.class).invoke(null, server);
92+
} catch (ClassNotFoundException ignored) {
93+
// Expected in the regular public build.
94+
} catch (ReflectiveOperationException exception) {
95+
LOGGER.error("Failed to invoke optional Mythic lifecycle hook '{}'.", methodName, exception);
96+
}
97+
}
98+
7699
private void registerCapabilities(RegisterCapabilitiesEvent event) {
77100
event.registerBlockEntity(Capabilities.ItemHandler.BLOCK,
78101
ModBlockEntities.TERMINAL_LOGGER.get(),
@@ -104,5 +127,4 @@ private void registerDisplaySourceBindings(FMLCommonSetupEvent event) {
104127
OrderTerminalDisplaySource.INSTANCE);
105128
});
106129
}
107-
108130
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.innygr.icreateordernetworking.mythic;
2+
3+
import com.google.gson.JsonObject;
4+
import com.google.gson.JsonParser;
5+
import net.neoforged.fml.loading.FMLPaths;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
import java.io.IOException;
10+
import java.net.URI;
11+
import java.net.http.HttpClient;
12+
import java.net.http.HttpRequest;
13+
import java.net.http.HttpResponse;
14+
import java.nio.charset.StandardCharsets;
15+
import java.nio.file.Files;
16+
import java.nio.file.Path;
17+
import java.time.Duration;
18+
import java.util.concurrent.ExecutorService;
19+
import java.util.concurrent.Executors;
20+
21+
/**
22+
* Server-only Discord notifier for the optional Mythic build.
23+
*
24+
* A Discord webhook is used instead of a bot token: it is configured only on
25+
* the dedicated server and is never sent to clients or embedded in the JAR.
26+
*/
27+
public final class DiscordBridge {
28+
private static final Logger LOGGER = LoggerFactory.getLogger("iCreateOrderNetworking/MythicDiscord");
29+
private static final Path CONFIG_PATH = FMLPaths.CONFIGDIR.get().resolve("icreate-order-networking-mythic.json");
30+
private static final HttpClient HTTP = HttpClient.newBuilder()
31+
.connectTimeout(Duration.ofSeconds(5))
32+
.build();
33+
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(runnable -> {
34+
Thread thread = new Thread(runnable, "icreate-mythic-discord");
35+
thread.setDaemon(true);
36+
return thread;
37+
});
38+
39+
private static volatile String webhookUrl = "";
40+
private static volatile String serverName = "Minecraft server";
41+
private static volatile boolean enabled;
42+
private static volatile boolean stopped;
43+
44+
private DiscordBridge() {}
45+
46+
static void configure(boolean isEnabled, String webhook, String configuredServerName) {
47+
enabled = isEnabled;
48+
webhookUrl = webhook == null ? "" : webhook.strip();
49+
serverName = configuredServerName == null || configuredServerName.isBlank()
50+
? "Minecraft server" : configuredServerName.strip();
51+
stopped = false;
52+
}
53+
54+
public static boolean isConfigured() {
55+
return enabled && isHttpsWebhook(webhookUrl);
56+
}
57+
58+
public static void sendEvent(String eventType, String details) {
59+
if (stopped || !isConfigured()) return;
60+
String safeType = sanitize(eventType, 80);
61+
String safeDetails = sanitize(details, 1500);
62+
JsonObject payload = new JsonObject();
63+
payload.addProperty("content", "**[" + serverName + "] " + safeType + "**\n" + safeDetails);
64+
JsonObject allowedMentions = new JsonObject();
65+
allowedMentions.add("parse", new com.google.gson.JsonArray());
66+
payload.add("allowed_mentions", allowedMentions);
67+
68+
HttpRequest request;
69+
try {
70+
request = HttpRequest.newBuilder(URI.create(webhookUrl))
71+
.timeout(Duration.ofSeconds(8))
72+
.header("Content-Type", "application/json; charset=utf-8")
73+
.POST(HttpRequest.BodyPublishers.ofString(payload.toString(), StandardCharsets.UTF_8))
74+
.build();
75+
} catch (IllegalArgumentException exception) {
76+
LOGGER.warn("Mythic Discord webhook URL is invalid; event was not sent.");
77+
return;
78+
}
79+
80+
EXECUTOR.submit(() -> {
81+
try {
82+
HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
83+
if (response.statusCode() < 200 || response.statusCode() >= 300) {
84+
LOGGER.warn("Mythic Discord webhook returned HTTP {}; event was not delivered.", response.statusCode());
85+
}
86+
} catch (InterruptedException exception) {
87+
Thread.currentThread().interrupt();
88+
} catch (IOException | RuntimeException exception) {
89+
LOGGER.warn("Unable to deliver Mythic Discord event: {}", exception.getMessage());
90+
}
91+
});
92+
}
93+
94+
static void shutdown() {
95+
stopped = true;
96+
}
97+
98+
private static boolean isHttpsWebhook(String value) {
99+
if (value == null || value.isBlank()) return false;
100+
try {
101+
URI uri = URI.create(value);
102+
return "https".equalsIgnoreCase(uri.getScheme())
103+
&& uri.getHost() != null
104+
&& uri.getHost().equalsIgnoreCase("discord.com")
105+
&& uri.getPath() != null
106+
&& uri.getPath().startsWith("/api/webhooks/");
107+
} catch (IllegalArgumentException exception) {
108+
return false;
109+
}
110+
}
111+
112+
private static String sanitize(String value, int maxLength) {
113+
if (value == null) return "n/a";
114+
String normalized = value.replace("@everyone", "@\u200beveryone")
115+
.replace("@here", "@\u200bhere")
116+
.replace("\r", " ")
117+
.strip();
118+
return normalized.length() > maxLength ? normalized.substring(0, maxLength) : normalized;
119+
}
120+
}

0 commit comments

Comments
 (0)