diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22743cec..af82ac4c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: @@ -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 @@ -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 diff --git a/README.md b/README.md index 4d1648b4..3f682bf6 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 5a4e3cf1..2305a543 100644 --- a/build.gradle +++ b/build.gradle @@ -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" } @@ -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() diff --git a/docs/MYTHIC_DISCORD_INTEGRATION.md b/docs/MYTHIC_DISCORD_INTEGRATION.md new file mode 100644 index 00000000..8109b058 --- /dev/null +++ b/docs/MYTHIC_DISCORD_INTEGRATION.md @@ -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. diff --git a/gradle.properties b/gradle.properties index fdc3c170..de7d723f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/java/com/innygr/icreateordernetworking/ICreateOrderNetworking.java b/src/main/java/com/innygr/icreateordernetworking/ICreateOrderNetworking.java index 3dc28dac..fdaf16a5 100644 --- a/src/main/java/com/innygr/icreateordernetworking/ICreateOrderNetworking.java +++ b/src/main/java/com/innygr/icreateordernetworking/ICreateOrderNetworking.java @@ -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; @@ -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(); @@ -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(), @@ -104,5 +127,4 @@ private void registerDisplaySourceBindings(FMLCommonSetupEvent event) { OrderTerminalDisplaySource.INSTANCE); }); } - } diff --git a/src/main/java/com/innygr/icreateordernetworking/mythic/DiscordBridge.java b/src/main/java/com/innygr/icreateordernetworking/mythic/DiscordBridge.java new file mode 100644 index 00000000..fcdef75f --- /dev/null +++ b/src/main/java/com/innygr/icreateordernetworking/mythic/DiscordBridge.java @@ -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 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; + } +} diff --git a/src/main/java/com/innygr/icreateordernetworking/mythic/FtbTeamsSync.java b/src/main/java/com/innygr/icreateordernetworking/mythic/FtbTeamsSync.java new file mode 100644 index 00000000..e1a9703b --- /dev/null +++ b/src/main/java/com/innygr/icreateordernetworking/mythic/FtbTeamsSync.java @@ -0,0 +1,92 @@ +package com.innygr.icreateordernetworking.mythic; + +import com.google.gson.JsonElement; +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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Loads the server-admin-managed mapping between FTB Teams IDs and Discord + * team names. The mapping is local to the dedicated server and contains no + * bot credentials. It is deliberately read-only: this bridge never edits FTB + * Teams membership or grants permissions. + */ +public final class FtbTeamsSync { + private static final Logger LOGGER = LoggerFactory.getLogger("iCreateOrderNetworking/FtbTeams"); + private static final Path CONFIG_PATH = FMLPaths.CONFIGDIR.get().resolve("icreate-order-networking-mythic-teams.json"); + private static volatile Map mappings = Collections.emptyMap(); + + private FtbTeamsSync() {} + + static void load() { + try { + if (Files.notExists(CONFIG_PATH)) { + Files.createDirectories(CONFIG_PATH.getParent()); + Files.writeString(CONFIG_PATH, defaultConfig(), StandardCharsets.UTF_8); + } + JsonObject root = JsonParser.parseString(Files.readString(CONFIG_PATH, StandardCharsets.UTF_8)).getAsJsonObject(); + if (root.get("schemaVersion") == null || root.get("schemaVersion").getAsInt() != 1 + || root.get("teams") == null || !root.get("teams").isJsonObject()) { + throw new IllegalStateException("expected schemaVersion 1 and a teams object"); + } + + Map loaded = new HashMap<>(); + for (Map.Entry entry : root.getAsJsonObject("teams").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject team = entry.getValue().getAsJsonObject(); + String name = readString(team, "name"); + String discordRoleId = readString(team, "discordRoleId"); + if (name.isBlank()) { + LOGGER.warn("Ignoring FTB team mapping '{}' because its name is blank.", entry.getKey()); + continue; + } + loaded.put(entry.getKey(), new TeamMapping(name, discordRoleId)); + } + mappings = Collections.unmodifiableMap(loaded); + LOGGER.info("Loaded {} Mythic FTB team mapping(s).", loaded.size()); + } catch (IOException | RuntimeException exception) { + mappings = Collections.emptyMap(); + LOGGER.warn("Could not load Mythic FTB team mappings from {}: {}", CONFIG_PATH, exception.getMessage()); + } + } + + public static TeamMapping find(String ftbTeamId) { + return ftbTeamId == null ? null : mappings.get(ftbTeamId); + } + + public static String displayName(String ftbTeamId, String fallback) { + TeamMapping mapping = find(ftbTeamId); + return mapping == null ? (fallback == null || fallback.isBlank() ? "Unassigned team" : fallback) : mapping.name(); + } + + private static String readString(JsonObject object, String key) { + JsonElement value = object.get(key); + return value != null && value.isJsonPrimitive() ? value.getAsString().strip() : ""; + } + + private static String defaultConfig() { + return """ + { + "schemaVersion": 1, + "teams": { + "replace-with-ftb-team-id": { + "name": "Example team", + "discordRoleId": "" + } + } + } + """; + } + + public record TeamMapping(String name, String discordRoleId) {} +} diff --git a/src/main/java/com/innygr/icreateordernetworking/mythic/MythicIntegration.java b/src/main/java/com/innygr/icreateordernetworking/mythic/MythicIntegration.java new file mode 100644 index 00000000..c2e43930 --- /dev/null +++ b/src/main/java/com/innygr/icreateordernetworking/mythic/MythicIntegration.java @@ -0,0 +1,136 @@ +package com.innygr.icreateordernetworking.mythic; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +/** + * Optional server-only Mythic integration. This class is compiled into the + * Mythic variant only; the regular public build excludes this package. + */ +public final class MythicIntegration { + private static final Logger LOGGER = LoggerFactory.getLogger("iCreateOrderNetworking/Mythic"); + private static final Path CONFIG_PATH = net.neoforged.fml.loading.FMLPaths.CONFIGDIR.get() + .resolve("icreate-order-networking-mythic.json"); + private static volatile MythicIntegration active; + private boolean enabled; + private boolean registered; + + private MythicIntegration() {} + + public static void onServerStarted(MinecraftServer server) { + MythicIntegration integration = new MythicIntegration(); + integration.loadConfig(); + FtbTeamsSync.load(); + active = integration; + if (integration.enabled) { + NeoForge.EVENT_BUS.register(integration); + integration.registered = true; + DiscordBridge.configure(true, integration.webhookUrl, integration.serverName); + DiscordBridge.sendEvent("Server online", "Mythic Discord integration connected."); + LOGGER.info("Mythic server integration enabled."); + } else { + DiscordBridge.configure(false, "", integration.serverName); + LOGGER.info("Mythic server integration is disabled in config."); + } + } + + public static void onServerStopped(MinecraftServer server) { + MythicIntegration integration = active; + if (integration != null && integration.registered) { + NeoForge.EVENT_BUS.unregister(integration); + } + DiscordBridge.sendEvent("Server offline", "The Minecraft server is shutting down."); + DiscordBridge.shutdown(); + active = null; + } + + /** Hook for the missile assembly/launch implementation to report a launch. */ + public static void missileLaunched(UUID missileId, String teamId, String teamName, String target) { + String resolvedTeam = FtbTeamsSync.displayName(teamId, teamName); + DiscordBridge.sendEvent("Missile launched", + "Missile: " + safeId(missileId) + "\nTeam: " + resolvedTeam + "\nTarget: " + safe(target)); + } + + /** Hook for the missile impact handler to report a resolved impact. */ + public static void missileImpacted(UUID missileId, String teamId, String teamName, String result, String location) { + String resolvedTeam = FtbTeamsSync.displayName(teamId, teamName); + DiscordBridge.sendEvent("Missile impact", + "Missile: " + safeId(missileId) + "\nTeam: " + resolvedTeam + "\nResult: " + + safe(result) + "\nLocation: " + safe(location)); + } + + /** Hook for interception/defence code to report a successful interception. */ + public static void missileIntercepted(UUID missileId, String interceptor, String location) { + DiscordBridge.sendEvent("Missile intercepted", + "Missile: " + safeId(missileId) + "\nIntercepted by: " + safe(interceptor) + + "\nLocation: " + safe(location)); + } + + private String webhookUrl = ""; + private String serverName = "Minecraft server"; + + private void loadConfig() { + try { + if (Files.notExists(CONFIG_PATH)) { + Files.createDirectories(CONFIG_PATH.getParent()); + Files.writeString(CONFIG_PATH, """ + { + "enabled": false, + "serverName": "Minecraft server", + "discordWebhookUrl": "" + } + """, StandardCharsets.UTF_8); + } + com.google.gson.JsonObject config = com.google.gson.JsonParser + .parseString(Files.readString(CONFIG_PATH, StandardCharsets.UTF_8)).getAsJsonObject(); + enabled = config.has("enabled") && config.get("enabled").getAsBoolean(); + serverName = config.has("serverName") ? config.get("serverName").getAsString() : "Minecraft server"; + webhookUrl = config.has("discordWebhookUrl") ? config.get("discordWebhookUrl").getAsString() : ""; + if (enabled && !isValidWebhook(webhookUrl)) { + enabled = false; + LOGGER.warn("Mythic integration disabled: discordWebhookUrl must be a Discord HTTPS webhook URL."); + } + } catch (Exception exception) { + enabled = false; + LOGGER.warn("Could not load Mythic integration config from {}: {}", CONFIG_PATH, exception.getMessage()); + } + } + + @net.neoforged.bus.api.SubscribeEvent + public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + DiscordBridge.sendEvent("Player joined", player.getGameProfile().getName()); + } + } + + @net.neoforged.bus.api.SubscribeEvent + public void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer player) { + DiscordBridge.sendEvent("Player left", player.getGameProfile().getName()); + } + } + + private static boolean isValidWebhook(String value) { + try { + java.net.URI uri = java.net.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 safeId(UUID id) { return id == null ? "unknown" : id.toString(); } + private static String safe(String value) { return value == null || value.isBlank() ? "unknown" : value.strip(); } +}