From 18e26c6320d2cd83f89992f3c8b6246df4e81806 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Sun, 24 May 2026 21:29:02 +0300 Subject: [PATCH 1/6] feat(config): redesign config system around TOML --- README.md | 191 +++--- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../controller/server/DataController.java | 72 ++- .../controller/server/MaintainController.java | 10 +- .../server/RuntimeToggleConfigService.java | 13 +- .../xcore/plugin/config/ConfigFactory.java | 59 +- .../xcore/plugin/config/ConfigTomlLoader.java | 284 +++++++++ .../xcore/plugin/config/ConfigTomlMapper.java | 516 +++++++++++++++ .../config/ConfigTomlTemplateWriter.java | 195 ++++++ .../org/xcore/plugin/config/GlobalConfig.java | 13 +- .../config/ServerLocalConfigPathEditor.java | 220 +++++++ .../config/ServerLocalConfigTomlRenderer.java | 33 + .../config/ServerLocalConfigTomlStore.java | 77 +++ .../plugin/config/TomlSecretsConfig.java | 245 ++++++++ .../xcore/plugin/config/TomlXcoreConfig.java | 267 ++++++++ .../controller/server/DataControllerTest.java | 240 ++++++- .../server/MaintainControllerTest.java | 22 +- .../plugin/config/ConfigFactoryTest.java | 165 ++++- .../plugin/config/ConfigTomlLoaderTest.java | 213 +++++++ .../plugin/config/ConfigTomlMapperTest.java | 593 ++++++++++++++++++ .../xcore/plugin/config/GlobalConfigTest.java | 10 +- .../ServerLocalConfigPathEditorTest.java | 103 +++ .../ServerLocalConfigTomlStoreTest.java | 105 ++++ .../plugin/config/TomlSecretsConfigTest.java | 176 ++++++ .../plugin/config/TomlXcoreConfigTest.java | 174 +++++ 26 files changed, 3810 insertions(+), 189 deletions(-) create mode 100644 src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java create mode 100644 src/main/java/org/xcore/plugin/config/ConfigTomlMapper.java create mode 100644 src/main/java/org/xcore/plugin/config/ConfigTomlTemplateWriter.java create mode 100644 src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java create mode 100644 src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java create mode 100644 src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java create mode 100644 src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java create mode 100644 src/main/java/org/xcore/plugin/config/TomlXcoreConfig.java create mode 100644 src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java create mode 100644 src/test/java/org/xcore/plugin/config/ConfigTomlMapperTest.java create mode 100644 src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java create mode 100644 src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java create mode 100644 src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java create mode 100644 src/test/java/org/xcore/plugin/config/TomlXcoreConfigTest.java diff --git a/README.md b/README.md index 352faebf..b551a2dd 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Multifunctional plugin for XCore Mindustry servers. Provides player profiles, cr ./gradlew shadowJar ``` 4. Copy the resulting `.jar` file from `build/libs/` to your Mindustry server's `config/mods` folder. -5. Configure your MongoDB and Redis connections in `/config/xcconfig.json` (server-specific) and `~/secrets.json` (global/shared). +5. Configure your MongoDB and Redis connections in `/config/xcore.toml` (server-local) and `secrets.toml` (global/shared, in your home directory by default). If legacy `xcconfig.json` or `secrets.json` files already exist, XCore migrates them to TOML automatically on startup and keeps backup copies. ## Commands @@ -161,8 +161,8 @@ Multifunctional plugin for XCore Mindustry servers. Provides player profiles, cr | `disable-feature ` | Disable a feature (`rtv`, `vnw`). | | `enable-feature ` | Re-enable a disabled feature. | | `disabled-features` | List disabled features. | -| `xconfig` | Show current XCore configuration. | -| `xconfig ` | Edit a configuration field. | +| `xconfig` | Show current server-local XCore configuration. | +| `xconfig ` | Edit a server-local config value by TOML path or legacy field alias. | | `edit-data ` | Edit a player's database entry. | | `dbinfo ` | Show raw player database JSON. | | `players` | List online players with IDs and IPs. | @@ -180,98 +180,127 @@ Multifunctional plugin for XCore Mindustry servers. Provides player profiles, cr ## Configuration -Configuration is split into two files: +Configuration is split into two TOML files: -- **Server-specific**: `/config/xcconfig.json` — created automatically on first start if missing. -- **Global/shared**: `~/secrets.json` — created automatically in the user's home directory if missing. Holds sensitive and shared settings such as the MongoDB connection string. +- **Server-local**: `/config/xcore.toml` — created automatically on first start if missing. +- **Global/shared**: `secrets.toml` — created automatically in the user's home directory if missing, or in the directory configured by `paths.global_config_directory`. -### `xcconfig.json` (server-specific) +If legacy `xcconfig.json` or `secrets.json` files are present, XCore migrates them to TOML on startup and keeps backup copies automatically. + +### `xcore.toml` (server-local) + +`xconfig` reads and writes this file. Prefer TOML-style dotted paths such as `transport.redis.url` or `translation.pipeline`; legacy flat field aliases are still accepted for compatibility. Runtime toggle paths under `runtime.disabled_commands` and `runtime.disabled_features` are intentionally managed by `disable-cmd` / `enable-cmd` and `disable-feature` / `enable-feature` instead of `xconfig`. + +| Field | Default | Description | +|-------|---------|-------------| +| `version` | `1` | Config schema version for future migrations. | +| `server.name` | `server` | Server identity name used for transport routing and cross-server recognition. | +| `server.public_host_override` | `""` | Public host/IP override. Leave blank for auto-detect. | +| `server.player_limit` | `30` | Base player slot limit. Admin players do not count toward this limit. | +| `server.console_enabled` | `true` | Whether the server console is enabled. | +| `server.game_started_timer` | `true` | Whether the game-start timer is active. | +| `paths.global_config_directory` | `""` | Override directory for `secrets.toml`. Leave blank to use the user's home directory. | +| `discord.channel_id` | `0` | Discord channel ID for server relay output. | +| `transport.redis.url` | `redis://127.0.0.1:6379` | Redis connection URI for the transport backend. | +| `transport.redis.group_prefix` | `xcore:cg` | Consumer group prefix for Redis streams. | +| `transport.redis.consumer_name` | `xcore-node` | Unique consumer name within the Redis consumer group. | +| `transport.redis.reclaim.enabled` | `true` | Whether to reclaim pending stream messages on start. | +| `transport.redis.reclaim.min_idle_ms` | `15000` | Minimum idle time (ms) before a message is considered orphaned and reclaimed. | +| `transport.redis.reclaim.batch` | `50` | Maximum number of messages to reclaim per batch. | +| `transport.redis.dlq.enabled` | `true` | Whether failed deliveries are sent to a dead-letter queue. | +| `transport.redis.dlq.max_delivery_attempts` | `3` | Maximum delivery attempts before a message is moved to the DLQ. | +| `transport.redis.dlq.prefix` | `xcore:dlq` | Redis key prefix for the dead-letter queue. | +| `runtime.disabled_commands` | `[]` | Command paths disabled at runtime (for example `["rtv"]`). Prefer dedicated toggle commands for editing this list at runtime. | +| `runtime.disabled_features` | `[]` | Disabled feature keys (`rtv`, `vnw`). Prefer dedicated toggle commands for editing this list at runtime. | +| `event_hub.enabled` | `false` | Whether the current map is the event hub. | +| `event_hub.map_id` | `""` | Internal map identifier for the event hub. | + +#### Translation settings (`[translation]`, `[translation.cache]`, `[translation.metrics]`, `[translation.llm]`) | Field | Default | Description | |-------|---------|-------------| -| `server` | `server` | Server identity name used for transport routing and cross-server recognition. | -| `discord_channel_id` | `0` | Discord channel ID for server relay output. | -| `redis_url` | `redis://127.0.0.1:6379` | Redis connection URI for the transport backend. | -| `redis_group_prefix` | `xcore:cg` | Consumer group prefix for Redis streams. | -| `redis_consumer_name` | `xcore-node` | Unique consumer name within the Redis consumer group. | -| `redis_reclaim_enabled` | `true` | Whether to reclaim pending stream messages on start. | -| `redis_reclaim_min_idle_ms` | `15000` | Minimum idle time (ms) before a message is considered orphaned and reclaimed. | -| `redis_reclaim_batch` | `50` | Maximum number of messages to reclaim per batch. | -| `redis_dlq_enabled` | `true` | Whether failed deliveries are sent to a dead-letter queue. | -| `redis_max_delivery_attempts` | `3` | Maximum delivery attempts before a message is moved to the DLQ. | -| `redis_dlq_prefix` | `xcore:dlq` | Redis key prefix for the dead-letter queue. | -| `console_enabled` | `true` | Whether the server console is enabled. | -| `player_limit` | `30` | Base player slot limit. Admin players do not count toward this limit. | -| `global_config_directory` | `null` | Override directory for the global `secrets.json`. If `null`, the user's home directory is used. | -| `game_started_timer` | `true` | Whether the game-start timer is active. | -| `disabled_commands` | `[]` | List of command paths disabled at runtime (e.g., `["rtv"]`). | -| `disabled_features` | `[]` | List of disabled feature keys (`rtv`, `vnw`). | -| `is_event_hub_map` | `false` | Whether the current map is the event hub. | -| `event_hub_map_id` | `""` | Internal map identifier for the event hub. | -| `translation` | (see below) | Translation pipeline settings. | - -#### Translation settings (`translation` object) +| `translation.enabled` | `true` | Master switch for the translation pipeline. | +| `translation.pipeline` | `["google"]` | Ordered list of provider IDs to try (for example `["google", "openai"]`). | +| `translation.preserve_original_message_on_failure` | `true` | Whether to send the original untranslated message if all providers fail. | +| `translation.cache.enabled` | `true` | Whether translation results are cached in Redis. | +| `translation.cache.ttl_seconds` | `1800` | Cache entry lifetime in seconds. | +| `translation.cache.max_text_length` | `500` | Maximum text length eligible for caching. | +| `translation.metrics.enabled` | `true` | Whether translation metrics are collected. | +| `translation.metrics.minute_buckets_enabled` | `true` | Whether per-minute metric buckets are maintained. | +| `translation.metrics.minute_bucket_ttl_seconds` | `21600` | TTL for per-minute metric buckets. | +| `translation.llm.preserve_formatting_tokens` | `true` | Whether Mindustry formatting tokens are preserved when sending text to LLM-based providers. | +| `translation.llm.structured_output_required` | `true` | Whether structured JSON output is requested from LLM providers. | +| `translation.llm.max_input_chars` | `500` | Maximum input characters for LLM translation requests. | +| `translation.llm.max_output_chars` | `1200` | Maximum output characters for LLM translation responses. | +| `translation.llm.strip_control_characters` | `true` | Whether control characters are stripped before translation. | + +#### IP reputation settings (`[ip_reputation]`) + +| Field | Default | Description | +|-------|---------|-------------| +| `ip_reputation.enabled` | `false` | Master switch for IP reputation checks. | +| `ip_reputation.block_proxy` | `true` | Whether proxy connections are blocked. | +| `ip_reputation.block_vpn` | `true` | Whether VPN connections are blocked. | +| `ip_reputation.block_tor` | `true` | Whether Tor exit nodes are blocked. | +| `ip_reputation.block_hosting` | `false` | Whether hosting-provider IP ranges are blocked. | +| `ip_reputation.cache_ttl_seconds` | `3600` | Cache lifetime for IP reputation lookups. | + +### `secrets.toml` (global/shared) + +This file contains sensitive and shared settings. **It is created automatically** with defaults. The required database settings are `database.mongo_connection_string` and `database.name` under the `[database]` section. | Field | Default | Description | |-------|---------|-------------| -| `enabled` | `true` | Master switch for the translation pipeline. | -| `pipeline` | `["google"]` | Ordered list of provider IDs to try (e.g., `["google", "openai"]`). | -| `preserve_original_message_on_failure` | `true` | Whether to send the original untranslated message if all providers fail. | -| `cache.enabled` | `true` | Whether translation results are cached in Redis. | -| `cache.ttl_seconds` | `1800` | Cache entry lifetime in seconds. | -| `cache.max_text_length` | `500` | Maximum text length eligible for caching. | -| `metrics.enabled` | `true` | Whether translation metrics are collected. | -| `metrics.minute_buckets_enabled` | `true` | Whether per-minute metric buckets are maintained. | -| `metrics.minute_bucket_ttl_seconds` | `21600` | TTL for per-minute metric buckets. | -| `llm.preserve_formatting_tokens` | `true` | Whether Mindustry formatting tokens are preserved when sending text to LLM-based providers. | -| `llm.structured_output_required` | `true` | Whether structured JSON output is requested from LLM providers. | -| `llm.max_input_chars` | `500` | Maximum input characters for LLM translation requests. | -| `llm.max_output_chars` | `1200` | Maximum output characters for LLM translation responses. | -| `llm.strip_control_characters` | `true` | Whether control characters are stripped before translation. | - -### `~/secrets.json` (global) - -This file contains sensitive and shared settings. **It is created automatically** with defaults; the only required fields are `mongo_connection_string` and `database_name`. +| `version` | `1` | Config schema version for future migrations. | +| `database.mongo_connection_string` | `""` | **Required.** MongoDB connection URI (for example `mongodb://localhost:27017`). | +| `database.name` | `""` | **Required.** MongoDB database name (for example `xcore`). | +| `database.read_only` | `false` | When `true`, database writes are suppressed. | +| `database.migration_enabled` | `false` | When `true`, migration logic runs on startup. | +| `external_links.discord_url` | `https://discord.gg/RUMCCa9QAC` | Public Discord invite URL. | +| `external_links.github_url` | `https://github.com/XCore-mindustry/` | Project GitHub URL. | +| `external_links.donatello_url` | `https://donatello.to/xcore` | Donation page URL. | +| `external_links.weblate_url` | `https://xcore.eradication.fun/` | Weblate translation platform URL. | +| `external_links.discord_red_vs_blue_url` | `https://discord.gg/UdnuFetcNt` | Red vs Blue Discord URL. | +| `moderation.votekick.min_play_time_minutes` | `60` | Minimum playtime (minutes) required to start a votekick. | +| `moderation.votekick.ban_duration_minutes` | `30` | Duration of a votekick ban in minutes. | +| `moderation.votekick.vote_duration_seconds` | `60.0` | How long a vote session remains open. | +| `chat.global.min_play_time_minutes` | `240` | Minimum playtime (minutes) required to use global chat (`/g`). | +| `maps.voting.switch_delay_seconds` | `10` | Delay before switching maps after a successful vote. | +| `pagination.events_per_page` | `10` | Events shown per page in the event menu. | +| `pagination.maps_per_page` | `10` | Maps shown per page in the map browser. | +| `pagination.commands_per_page` | `6` | Commands shown per page in the help menu. | +| `pagination.private_messages_per_page` | `10` | Messages shown per page in the inbox. | +| `messages.history.max_history` | `16` | Maximum tracked message history per player. | +| `messages.private.max_length` | `300` | Maximum length of a private message. | +| `messages.private.cooldown_seconds` | `10` | Cooldown between private messages from the same player. | +| `messages.private.unread_limit` | `30` | Maximum unread messages retained. | +| `messages.private.blocked_limit` | `100` | Maximum blocked-player entries retained. | + +#### Translation provider config (`[translation.providers.]`) | Field | Default | Description | |-------|---------|-------------| -| `mongo_connection_string` | `null` | **Required.** MongoDB connection URI (e.g., `mongodb://localhost:27017`). | -| `database_name` | `null` | **Required.** MongoDB database name (e.g., `xcore`). | -| `discord_url` | `https://discord.gg/RUMCCa9QAC` | Public Discord invite URL. | -| `github_url` | `https://github.com/XCore-mindustry/` | Project GitHub URL. | -| `donatello_url` | `https://donatello.to/xcore` | Donation page URL. | -| `weblate_url` | `https://xcore.eradication.fun/` | Weblate translation platform URL. | -| `discord_red_vs_blue_url` | `https://discord.gg/UdnuFetcNt` | Red vs Blue Discord URL. | -| `min_play_time_for_votekick` | `60` | Minimum playtime (minutes) required to start a votekick. | -| `min_play_time_for_global_chat` | `240` | Minimum playtime (minutes) required to use global chat (`/g`). | -| `vote_kick_ban_duration_minutes` | `30` | Duration of a votekick ban in minutes. | -| `vote_duration_seconds` | `60.0` | How long a vote session remains open. | -| `map_switch_delay_seconds` | `10` | Delay before switching maps after a successful vote. | -| `events_per_page` | `10` | Events shown per page in the event menu. | -| `maps_per_page` | `10` | Maps shown per page in the map browser. | -| `commands_per_page` | `6` | Commands shown per page in the help menu. | -| `private_messages_per_page` | `10` | Messages shown per page in the inbox. | -| `max_history` | `16` | Maximum tracked message history per player. | -| `private_message_max_length` | `300` | Maximum length of a private message. | -| `private_message_cooldown_seconds` | `10` | Cooldown between private messages from the same player. | -| `private_message_unread_limit` | `30` | Maximum unread messages retained. | -| `private_message_blocked_limit` | `100` | Maximum blocked-player entries retained. | -| `is_data_base_read_only` | `false` | When `true`, database writes are suppressed. | -| `is_data_base_migration` | `false` | When `true`, migration logic runs on startup. | -| `translation_providers` | `{ "google": { ... } }` | Map of provider configurations keyed by provider ID. | - -#### Translation provider config (`translationProviders.`) +| `translation.providers..type` | `google` | Provider type (`google`, `openai`, `nvidia`, etc.). | +| `translation.providers..enabled` | `true` | Whether this provider is active. | +| `translation.providers..api_key` | `""` | API key for the provider. | +| `translation.providers..base_url` | `https://api.openai.com/v1` | Base URL for the provider API. | +| `translation.providers..model` | `gpt-5.4` | Model identifier for provider integrations that need one. | +| `translation.providers..api_mode` | `""` | Provider-specific API mode. | +| `translation.providers..organization` | `""` | Optional organization header/value for provider APIs. | +| `translation.providers..project` | `""` | Optional project header/value for provider APIs. | +| `translation.providers..timeout_seconds` | `15` | Request timeout. | +| `translation.providers..max_retries` | `1` | Number of retries on transient failure. | +| `translation.providers..temperature` | `0.0` | Temperature passed to LLM-style providers when applicable. | +| `translation.providers..supported_languages` | `[]` | Set/list of language codes this provider supports. | + +#### IP reputation provider config (`[ip_reputation.provider]`) | Field | Default | Description | |-------|---------|-------------| -| `type` | `google` | Provider type (`google`, `openai`, `nvidia`, etc.). | -| `enabled` | `true` | Whether this provider is active. | -| `api_key` | `null` | API key for the provider. | -| `base_url` | `https://api.openai.com/v1` | Base URL for the provider API. | -| `api_mode` | `null` | Provider-specific API mode. | -| `timeout_seconds` | `15` | Request timeout. | -| `max_retries` | `1` | Number of retries on transient failure. | -| `supported_languages` | `[]` | Set of language codes this provider supports. | +| `ip_reputation.provider.base_url` | `http://ip-api.com/json` | Base URL for the IP reputation provider. | +| `ip_reputation.provider.timeout_seconds` | `10` | Request timeout for IP reputation lookups. | +| `ip_reputation.provider.max_retries` | `2` | Number of retries on transient IP reputation failures. | +| `ip_reputation.provider.rate_limit_per_minute` | `45` | Soft per-minute rate limit for provider calls. | ## Localization Bundles are stored in `src/main/resources/bundles/` and distributed via FluBundle. Supported languages include English, Russian, Ukrainian, and Belarusian. Locale resolution follows player preference with automatic fallback. diff --git a/build.gradle.kts b/build.gradle.kts index 45e8d618..99485248 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(libs.cloud.mindustry) implementation(libs.mongodb.sync) implementation(libs.gson) + implementation(libs.jackson.dataformat.toml) implementation(libs.jbcrypt) implementation(libs.lettuce) implementation(variantOf(libs.netty.epoll) { classifier("linux-x86_64") }) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61cc22a6..a45743ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ jbcrypt = "0.4" netty = "4.1.107.Final" jline = "3.30.6" lettuce = "6.8.1.RELEASE" +jackson = "2.18.2" avaje = "12.3" flubundle = "1.3" lombok = "1.18.42" @@ -37,6 +38,7 @@ jline-terminal = { module = "org.jline:jline-terminal-jna", version.ref = "jline jline-reader = { module = "org.jline:jline-reader", version.ref = "jline" } jline-console = { module = "org.jline:jline-console", version.ref = "jline" } lettuce = { module = "io.lettuce:lettuce-core", version.ref = "lettuce" } +jackson-dataformat-toml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-toml", version.ref = "jackson" } avaje-inject = { module = "io.avaje:avaje-inject", version.ref = "avaje" } avaje-inject-generator = { module = "io.avaje:avaje-inject-generator", version.ref = "avaje" } avaje-inject-test = { module = "io.avaje:avaje-inject-test", version.ref = "avaje" } diff --git a/src/main/java/org/xcore/plugin/command/controller/server/DataController.java b/src/main/java/org/xcore/plugin/command/controller/server/DataController.java index d6019f78..4ebac28a 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/DataController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/DataController.java @@ -1,6 +1,5 @@ package org.xcore.plugin.command.controller.server; -import arc.files.Fi; import arc.util.Log; import arc.util.serialization.JsonReader; import arc.util.serialization.JsonValue; @@ -16,6 +15,9 @@ import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudServerController; import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.ServerLocalConfigPathEditor; +import org.xcore.plugin.config.ServerLocalConfigTomlRenderer; +import org.xcore.plugin.config.ServerLocalConfigTomlStore; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.FindService; @@ -23,60 +25,78 @@ @Singleton public class DataController implements CloudServerController { + private static final String DISABLED_COMMANDS_PATH = "runtime.disabled_commands"; + private static final String DISABLED_FEATURES_PATH = "runtime.disabled_features"; private final PlayerDataRepository playerDataRepository; - private final Fi configFile; private Config config; private final Gson prettyGson; private final FindService find; private final TopMenuCacheService topMenuCacheService; + private final ServerLocalConfigPathEditor pathEditor; + private final ServerLocalConfigTomlRenderer tomlRenderer; + private final ServerLocalConfigTomlStore tomlStore; @Inject public DataController(PlayerDataRepository playerDataRepository, - @Named("xcConfigFile") Fi configFile, Config config, @Named("pretty") Gson prettyGson, FindService find, - TopMenuCacheService topMenuCacheService) { + TopMenuCacheService topMenuCacheService, + ServerLocalConfigPathEditor pathEditor, + ServerLocalConfigTomlRenderer tomlRenderer, + ServerLocalConfigTomlStore tomlStore) { this.playerDataRepository = playerDataRepository; - this.configFile = configFile; this.config = config; this.prettyGson = prettyGson; this.find = find; this.topMenuCacheService = topMenuCacheService; + this.pathEditor = pathEditor; + this.tomlRenderer = tomlRenderer; + this.tomlStore = tomlStore; } public DataController(PlayerDataRepository playerDataRepository, - Fi configFile, Config config, Gson prettyGson, - FindService find) { - this(playerDataRepository, configFile, config, prettyGson, find, null); + FindService find, + ServerLocalConfigPathEditor pathEditor, + ServerLocalConfigTomlRenderer tomlRenderer, + ServerLocalConfigTomlStore tomlStore) { + this(playerDataRepository, config, prettyGson, find, null, pathEditor, tomlRenderer, tomlStore); } @Command("xconfig") - @CommandDescription("Displays the current XCore configuration JSON.") + @CommandDescription("Displays the current server-local XCore configuration. Use it to discover valid TOML paths and values.") public void xconfigShow(XCoreSender sender) { - Log.info(prettyGson.toJson(config)); + Log.info(tomlRenderer.render(config)); } @Command("xconfig ") - @CommandDescription("Modifies a specific field in the XCore configuration.") + @CommandDescription("Modifies a server-local XCore config value by legacy field name or TOML path. Lists may use comma-separated or JSON-array syntax.") public void xconfigEdit(XCoreSender sender, - @Argument(value = "field", description = "The JSON field name") String field, - @Argument(value = "value", description = "The new value") @Greedy String value) { + @Argument(value = "field", description = "Legacy field name or TOML-style dotted path") String field, + @Argument(value = "value", description = "The new value (for example true, 64, redis://..., google,openai, or [\"google\",\"openai\"])") @Greedy String value) { + if (isDedicatedRuntimeTogglePath(field)) { + Log.err("Path '@' is managed by dedicated toggle commands. Use disable-cmd/enable-cmd or disable-feature/enable-feature.", field); + return; + } - String json = prettyGson.toJson(config); - JsonValue root = new JsonReader().parse(json); + Config updated; + try { + updated = pathEditor.update(config, field, value); + } catch (IllegalArgumentException e) { + Log.err("@", e.getMessage()); + return; + } - if (!root.has(field)) { - Log.err("Field '@' not found in Config.", field); + if (updated == null) { + Log.err("Field '@' not found in Config. Use legacy aliases or TOML-style dotted paths such as 'server.player_limit' or 'transport.redis.url'.", field); return; } - modifyJson(root.get(field), value); - config = prettyGson.fromJson(root.toJson(JsonWriter.OutputType.json), Config.class); - configFile.writeString(prettyGson.toJson(config)); + config = updated; + tomlStore.write(config); Log.info("Config field '@' updated to '@'.", field, value); } @@ -109,6 +129,18 @@ public void editData(XCoreSender sender, Log.info("PlayerData for @ updated. Field '@' -> '@'.", data.nickname, field, value); } + private boolean isDedicatedRuntimeTogglePath(String field) { + if (field == null) { + return false; + } + + return switch (field.trim()) { + case "disabledCommands", DISABLED_COMMANDS_PATH -> true; + case "disabledFeatures", DISABLED_FEATURES_PATH -> true; + default -> false; + }; + } + @Command("dbinfo ") @CommandDescription("Displays raw database information (JSON) for a player.") public void dbInfo(XCoreSender sender, diff --git a/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java b/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java index 75e104b8..d1fe1cf1 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java @@ -1,7 +1,6 @@ package org.xcore.plugin.command.controller.server; import arc.Core; -import arc.files.Fi; import arc.struct.Seq; import org.xcore.plugin.common.PLog; import com.google.gson.Gson; @@ -19,6 +18,7 @@ import org.xcore.plugin.command.controller.CloudServerController; import org.xcore.plugin.common.PluginState; import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.ServerLocalConfigTomlStore; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.enums.Feature; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; @@ -61,7 +61,7 @@ public MaintainController(NetworkService network, MapIdentityAuditService mapIdentityAuditService, TopMenuCacheService topMenuCacheService, Config config, - @Named("xcConfigFile") Fi configFile, + ServerLocalConfigTomlStore tomlStore, @Named("pretty") Gson prettyGson) { this.network = network; this.playerDataRepository = playerDataRepository; @@ -70,7 +70,7 @@ public MaintainController(NetworkService network, this.config = config; this.mapIdentityAuditService = mapIdentityAuditService; this.topMenuCacheService = topMenuCacheService; - this.toggleConfigService = new RuntimeToggleConfigService(config, configFile, prettyGson); + this.toggleConfigService = new RuntimeToggleConfigService(config, tomlStore); } public MaintainController(NetworkService network, @@ -79,9 +79,9 @@ public MaintainController(NetworkService network, SessionService sessionService, MapIdentityAuditService mapIdentityAuditService, Config config, - Fi configFile, + ServerLocalConfigTomlStore tomlStore, Gson prettyGson) { - this(network, playerDataRepository, pluginState, sessionService, mapIdentityAuditService, null, config, configFile, prettyGson); + this(network, playerDataRepository, pluginState, sessionService, mapIdentityAuditService, null, config, tomlStore, prettyGson); } @Command("exit") diff --git a/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java b/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java index eab30ad7..80b71a72 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java @@ -1,8 +1,7 @@ package org.xcore.plugin.command.controller.server; -import arc.files.Fi; -import com.google.gson.Gson; import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.ServerLocalConfigTomlStore; import java.util.HashSet; import java.util.Locale; @@ -19,13 +18,11 @@ record ToggleMutationResult(boolean changed, String value) { } private final Config config; - private final Fi configFile; - private final Gson prettyGson; + private final ServerLocalConfigTomlStore tomlStore; - RuntimeToggleConfigService(Config config, Fi configFile, Gson prettyGson) { + RuntimeToggleConfigService(Config config, ServerLocalConfigTomlStore tomlStore) { this.config = config; - this.configFile = configFile; - this.prettyGson = prettyGson; + this.tomlStore = tomlStore; } ToggleMutationResult disable(ToggleTarget target, String value) { @@ -102,6 +99,6 @@ private Set mutableSet(ToggleTarget target) { } private void save() { - configFile.writeString(prettyGson.toJson(config)); + tomlStore.write(config); } } diff --git a/src/main/java/org/xcore/plugin/config/ConfigFactory.java b/src/main/java/org/xcore/plugin/config/ConfigFactory.java index 5cbb1c6f..b03342af 100644 --- a/src/main/java/org/xcore/plugin/config/ConfigFactory.java +++ b/src/main/java/org/xcore/plugin/config/ConfigFactory.java @@ -5,51 +5,62 @@ import io.avaje.inject.Bean; import io.avaje.inject.Factory; import jakarta.inject.Named; -import org.xcore.plugin.config.Config; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.common.PLog; import static mindustry.Vars.dataDirectory; @Factory public class ConfigFactory { - private static final Fi configFile = dataDirectory.child("xcconfig.json"); + private static final Fi legacyXcoreJsonFile = dataDirectory.child("xcconfig.json"); @Bean @Named("xcConfigFile") - public Fi xcConfigFile() { - return configFile; + public Fi legacyXcoreJsonFile() { + return legacyXcoreJsonFile; } @Bean public Config config(@Named("pretty") Gson gson) { - Config config; - if (configFile.exists()) { - config = gson.fromJson(configFile.reader(), Config.class); - } else { - config = new Config(); - configFile.writeString(gson.toJson(config)); - } + var result = ConfigTomlLoader.loadXcoreConfig(dataDirectory, gson); + logSource("Config", result.file, result.source); + Config config = result.config; config.normalize(); return config; } + @Bean + public ServerLocalConfigTomlStore serverLocalConfigTomlStore() { + return new ServerLocalConfigTomlStore(ConfigTomlLoader.resolveXcoreToml(dataDirectory)); + } + + @Bean + public ServerLocalConfigPathEditor serverLocalConfigPathEditor(@Named("pretty") Gson gson) { + return new ServerLocalConfigPathEditor(gson); + } + + @Bean + public ServerLocalConfigTomlRenderer serverLocalConfigTomlRenderer() { + return new ServerLocalConfigTomlRenderer(); + } + @Bean public GlobalConfig globalConfig(Config config, @Named("pretty") Gson gson) { - Fi globalConfigFile = Fi.get(config.globalConfigDirectory == null - ? System.getProperty("user.home") - : config.globalConfigDirectory).child("secrets.json"); - - GlobalConfig globalConfig; - if (globalConfigFile.exists()) { - globalConfig = gson.fromJson(globalConfigFile.reader(), GlobalConfig.class); - } else { - globalConfig = new GlobalConfig(); - globalConfigFile.writeString(gson.toJson(globalConfig)); - } + var result = ConfigTomlLoader.loadGlobalConfig(config.globalConfigDirectory, gson); + logSource("GlobalConfig", result.file, result.source); + GlobalConfig globalConfig = result.config; globalConfig.normalize(); - globalConfig.postInit(globalConfigFile); + globalConfig.postInit(result.file); return globalConfig; } + + private static void logSource(String label, Fi file, ConfigTomlLoader.Source source) { + switch (source) { + case TOML -> PLog.infoTag("Config", "Loaded @ from @", label, file.name()); + case LEGACY_JSON -> PLog.warnTag("Config", "Loaded @ from legacy @ (consider migrating to TOML)", label, file.name()); + case MIGRATED -> PLog.infoTag("Config", "Migrated @ to @", label, file.name()); + case DEFAULT_TEMPLATE -> PLog.infoTag("Config", "Created default @ at @", label, file.name()); + } + } } diff --git a/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java b/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java new file mode 100644 index 00000000..dbbbdbd6 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java @@ -0,0 +1,284 @@ +package org.xcore.plugin.config; + +import arc.files.Fi; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.dataformat.toml.TomlMapper; +import com.google.gson.Gson; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Helper for locating, loading, and defaulting TOML configuration files. + * + *

Resolution order for each config:

+ *
    + *
  1. If the TOML file exists, load it via Jackson TOML and map to the legacy model.
  2. + *
  3. Else if the legacy JSON file exists, migrate it to TOML, back up the JSON file, then reload from TOML.
  4. + *
  5. Else write a commented default TOML template, then load it.
  6. + *
+ * + *

This class is stateless and performs no logging. Callers are responsible + * for reporting the {@link Source} to startup logs.

+ */ +public final class ConfigTomlLoader { + private static final DateTimeFormatter BACKUP_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private static final String XCORE_TOML = "xcore.toml"; + private static final String XCORE_JSON = "xcconfig.json"; + private static final String SECRETS_TOML = "secrets.toml"; + private static final String SECRETS_JSON = "secrets.json"; + private static Supplier backupTimestampSupplier = LocalDateTime::now; + + private ConfigTomlLoader() { + } + + /** + * Identifies which source was used to produce a configuration object. + */ + public enum Source { + /** Loaded from an existing {@code xcore.toml} or {@code secrets.toml}. */ + TOML, + /** Loaded from a legacy {@code xcconfig.json} or {@code secrets.json}. */ + LEGACY_JSON, + /** Migrated from legacy JSON into TOML during this load. */ + MIGRATED, + /** Created from a default template because no config file existed. */ + DEFAULT_TEMPLATE + } + + /** + * Result of a config load operation, carrying the resolved object, the + * source that was used, and the file that was read or created. + * + * @param the configuration type, either {@link Config} or {@link GlobalConfig} + */ + public static final class LoadResult { + public final T config; + public final Source source; + public final Fi file; + public final Fi backupFile; + + LoadResult(T config, Source source, Fi file) { + this(config, source, file, null); + } + + LoadResult(T config, Source source, Fi file, Fi backupFile) { + this.config = config; + this.source = source; + this.file = file; + this.backupFile = backupFile; + } + } + + // ------------------------------------------------------------------ + // File resolution + // ------------------------------------------------------------------ + + /** + * Returns the {@code xcore.toml} file handle inside {@code dataDirectory}. + * + * @param dataDirectory the Mindustry data directory + * @return a {@link Fi} pointing to {@code /xcore.toml} + */ + public static Fi resolveXcoreToml(Fi dataDirectory) { + return dataDirectory.child(XCORE_TOML); + } + + /** + * Returns the legacy {@code xcconfig.json} file handle inside {@code dataDirectory}. + * + * @param dataDirectory the Mindustry data directory + * @return a {@link Fi} pointing to {@code /xcconfig.json} + */ + public static Fi resolveLegacyXcoreJson(Fi dataDirectory) { + return dataDirectory.child(XCORE_JSON); + } + + /** + * Returns the {@code secrets.toml} file handle inside the resolved global + * config directory. + * + * @param globalConfigDirectory explicit global directory, or {@code null} to use the user home + * @return a {@link Fi} pointing to {@code /secrets.toml} + */ + public static Fi resolveSecretsToml(String globalConfigDirectory) { + return resolveGlobalDir(globalConfigDirectory).child(SECRETS_TOML); + } + + /** + * Returns the legacy {@code secrets.json} file handle inside the resolved + * global config directory. + * + * @param globalConfigDirectory explicit global directory, or {@code null} to use the user home + * @return a {@link Fi} pointing to {@code /secrets.json} + */ + public static Fi resolveLegacySecretsJson(String globalConfigDirectory) { + return resolveGlobalDir(globalConfigDirectory).child(SECRETS_JSON); + } + + static Fi backupLegacyFile(Fi sourceFile) { + Objects.requireNonNull(sourceFile, "sourceFile must not be null"); + + Fi backupFile = sourceFile.sibling(sourceFile.name() + ".bak-" + currentBackupTimestamp()); + + try { + Files.move(sourceFile.file().toPath(), backupFile.file().toPath(), StandardCopyOption.REPLACE_EXISTING); + return backupFile; + } catch (IOException e) { + throw new IllegalStateException( + "Failed to back up legacy config " + sourceFile.absolutePath() + " to " + backupFile.absolutePath(), + e + ); + } + } + + static void setBackupTimestampSupplier(Supplier supplier) { + backupTimestampSupplier = Objects.requireNonNull(supplier, "supplier must not be null"); + } + + static void resetBackupTimestampSupplier() { + backupTimestampSupplier = LocalDateTime::now; + } + + // ------------------------------------------------------------------ + // Loading + // ------------------------------------------------------------------ + + /** + * Loads the server-local configuration following the TOML-first resolution order. + * + *

The returned {@link Config} is fully normalized regardless of source.

+ * + * @param dataDirectory the Mindustry data directory + * @param gson the Gson instance used for legacy JSON fallback + * @return a {@link LoadResult} containing the resolved {@link Config} + */ + public static LoadResult loadXcoreConfig(Fi dataDirectory, Gson gson) { + Fi tomlFile = resolveXcoreToml(dataDirectory); + if (tomlFile.exists()) { + TomlXcoreConfig toml = readToml(tomlFile, TomlXcoreConfig.class); + Config config = ConfigTomlMapper.toConfig(toml); + return new LoadResult<>(config, Source.TOML, tomlFile); + } + + Fi jsonFile = resolveLegacyXcoreJson(dataDirectory); + if (jsonFile.exists()) { + return migrateLegacyXcoreConfig(jsonFile, tomlFile, gson); + } + + ConfigTomlTemplateWriter.writeDefaultXcoreToml(tomlFile); + TomlXcoreConfig toml = readToml(tomlFile, TomlXcoreConfig.class); + Config config = ConfigTomlMapper.toConfig(toml); + return new LoadResult<>(config, Source.DEFAULT_TEMPLATE, tomlFile); + } + + /** + * Loads the shared global/secrets configuration following the TOML-first resolution order. + * + *

The returned {@link GlobalConfig} is fully normalized regardless of source. + * Callers should still invoke {@link GlobalConfig#postInit(Fi)} for required-field validation.

+ * + * @param globalConfigDirectory explicit global directory, or {@code null} to use the user home + * @param gson the Gson instance used for legacy JSON fallback + * @return a {@link LoadResult} containing the resolved {@link GlobalConfig} + */ + public static LoadResult loadGlobalConfig(String globalConfigDirectory, Gson gson) { + Fi tomlFile = resolveSecretsToml(globalConfigDirectory); + if (tomlFile.exists()) { + TomlSecretsConfig toml = readToml(tomlFile, TomlSecretsConfig.class); + GlobalConfig global = ConfigTomlMapper.toGlobalConfig(toml); + return new LoadResult<>(global, Source.TOML, tomlFile); + } + + Fi jsonFile = resolveLegacySecretsJson(globalConfigDirectory); + if (jsonFile.exists()) { + return migrateLegacySecretsConfig(jsonFile, tomlFile, gson); + } + + ConfigTomlTemplateWriter.writeDefaultSecretsToml(tomlFile); + TomlSecretsConfig toml = readToml(tomlFile, TomlSecretsConfig.class); + GlobalConfig global = ConfigTomlMapper.toGlobalConfig(toml); + return new LoadResult<>(global, Source.DEFAULT_TEMPLATE, tomlFile); + } + + // ------------------------------------------------------------------ + // Internal helpers + // ------------------------------------------------------------------ + + private static Fi resolveGlobalDir(String globalConfigDirectory) { + return Fi.get(globalConfigDirectory == null + ? System.getProperty("user.home") + : globalConfigDirectory); + } + + private static LoadResult migrateLegacyXcoreConfig(Fi jsonFile, Fi tomlFile, Gson gson) { + Config legacyConfig = gson.fromJson(jsonFile.reader(), Config.class); + if (legacyConfig == null) { + legacyConfig = new Config(); + } + legacyConfig.normalize(); + + TomlXcoreConfig migratedToml = ConfigTomlMapper.toTomlXcoreConfig(legacyConfig); + writeToml(tomlFile, migratedToml); + Fi backupFile = backupLegacyFile(jsonFile); + + Config migratedConfig = ConfigTomlMapper.toConfig(readToml(tomlFile, TomlXcoreConfig.class)); + return new LoadResult<>(migratedConfig, Source.MIGRATED, tomlFile, backupFile); + } + + private static LoadResult migrateLegacySecretsConfig(Fi jsonFile, Fi tomlFile, Gson gson) { + GlobalConfig legacyGlobal = gson.fromJson(jsonFile.reader(), GlobalConfig.class); + if (legacyGlobal == null) { + legacyGlobal = new GlobalConfig(); + } + legacyGlobal.normalize(); + + TomlSecretsConfig migratedToml = ConfigTomlMapper.toTomlSecretsConfig(legacyGlobal); + writeToml(tomlFile, migratedToml); + Fi backupFile = backupLegacyFile(jsonFile); + + GlobalConfig migratedGlobal = ConfigTomlMapper.toGlobalConfig(readToml(tomlFile, TomlSecretsConfig.class)); + return new LoadResult<>(migratedGlobal, Source.MIGRATED, tomlFile, backupFile); + } + + private static String currentBackupTimestamp() { + return backupTimestampSupplier.get().format(BACKUP_TIMESTAMP_FORMAT); + } + + private static void writeToml(Fi file, Object config) { + try { + var parent = file.file().toPath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + String tomlString = createTomlMapper().writeValueAsString(config); + file.writeString(tomlString); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to write TOML to " + file.absolutePath() + ": " + e.getMessage(), e); + } + } + + private static T readToml(Fi file, Class type) { + try { + return createTomlMapper().readValue(file.file(), type); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to read TOML from " + file.absolutePath() + ": " + e.getMessage(), e); + } + } + + private static TomlMapper createTomlMapper() { + return TomlMapper.builder() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .build(); + } +} diff --git a/src/main/java/org/xcore/plugin/config/ConfigTomlMapper.java b/src/main/java/org/xcore/plugin/config/ConfigTomlMapper.java new file mode 100644 index 00000000..501d9b67 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ConfigTomlMapper.java @@ -0,0 +1,516 @@ +package org.xcore.plugin.config; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Pure mapping helpers that convert TOML DTOs into the existing legacy runtime + * {@link Config} and {@link GlobalConfig} models. + * + *

This class is side-effect free and performs no file I/O. It normalizes + * input DTOs at the boundary and repairs null collections or blank optional + * strings so that the resulting legacy objects match current runtime + * expectations.

+ */ +public final class ConfigTomlMapper { + private ConfigTomlMapper() { + } + + /** + * Maps a normalized {@link TomlXcoreConfig} into a legacy {@link Config}. + * + *

The returned instance is fully initialized and normalized so that + * consumers do not need to call {@link Config#normalize()} again, + * although doing so is harmless.

+ * + * @param toml the TOML DTO to map; must not be {@code null} + * @return a new {@link Config} populated from the DTO + * @throws IllegalArgumentException if {@code toml} is {@code null} + */ + public static Config toConfig(TomlXcoreConfig toml) { + if (toml == null) { + throw new IllegalArgumentException("toml must not be null"); + } + toml.normalize(); + + Config config = new Config(); + + // server + config.server = toml.server.name; + config.publicHostOverride = toml.server.publicHostOverride; + config.playerLimit = toml.server.playerLimit; + config.consoleEnabled = toml.server.consoleEnabled; + config.gameStartedTimer = toml.server.gameStartedTimer; + + // paths + config.globalConfigDirectory = toml.paths.globalConfigDirectory; + + // discord + config.discordChannelId = toml.discord.channelId; + + // transport.redis + config.redisUrl = toml.transport.redis.url; + config.redisGroupPrefix = toml.transport.redis.groupPrefix; + config.redisConsumerName = toml.transport.redis.consumerName; + config.redisReclaimEnabled = toml.transport.redis.reclaim.enabled; + config.redisReclaimMinIdleMs = toml.transport.redis.reclaim.minIdleMs; + config.redisReclaimBatch = toml.transport.redis.reclaim.batch; + config.redisDlqEnabled = toml.transport.redis.dlq.enabled; + config.redisMaxDeliveryAttempts = toml.transport.redis.dlq.maxDeliveryAttempts; + config.redisDlqPrefix = toml.transport.redis.dlq.prefix; + + // runtime + config.disabledCommands = copySet(toml.runtime.disabledCommands); + config.disabledFeatures = copySet(toml.runtime.disabledFeatures); + + // event hub + config.isEventHubMap = toml.eventHub.enabled; + config.eventHubMapID = toml.eventHub.mapId; + + // translation + config.translation = mapTranslation(toml.translation); + + // ip reputation + config.ipReputation = mapIpReputation(toml.ipReputation); + + return config; + } + + /** + * Maps a normalized legacy {@link Config} into the structured + * {@link TomlXcoreConfig} DTO used for TOML persistence. + * + * @param config the legacy runtime config; must not be {@code null} + * @return a new {@link TomlXcoreConfig} populated from the legacy model + * @throws IllegalArgumentException if {@code config} is {@code null} + */ + public static TomlXcoreConfig toTomlXcoreConfig(Config config) { + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + config.normalize(); + + TomlXcoreConfig toml = new TomlXcoreConfig(); + + toml.server.name = config.server; + toml.server.publicHostOverride = nullToBlank(config.publicHostOverride); + toml.server.playerLimit = config.playerLimit; + toml.server.consoleEnabled = config.consoleEnabled; + toml.server.gameStartedTimer = config.gameStartedTimer; + + toml.paths.globalConfigDirectory = nullToBlank(config.globalConfigDirectory); + + toml.discord.channelId = config.discordChannelId; + + toml.transport.redis.url = config.redisUrl; + toml.transport.redis.groupPrefix = config.redisGroupPrefix; + toml.transport.redis.consumerName = config.redisConsumerName; + toml.transport.redis.reclaim.enabled = config.redisReclaimEnabled; + toml.transport.redis.reclaim.minIdleMs = config.redisReclaimMinIdleMs; + toml.transport.redis.reclaim.batch = config.redisReclaimBatch; + toml.transport.redis.dlq.enabled = config.redisDlqEnabled; + toml.transport.redis.dlq.maxDeliveryAttempts = config.redisMaxDeliveryAttempts; + toml.transport.redis.dlq.prefix = config.redisDlqPrefix; + + toml.runtime.disabledCommands = copyMutableSet(config.disabledCommands); + toml.runtime.disabledFeatures = copyMutableSet(config.disabledFeatures); + + toml.eventHub.enabled = config.isEventHubMap; + toml.eventHub.mapId = nullToBlank(config.eventHubMapID); + + toml.translation = mapTomlTranslation(config.translation); + toml.ipReputation = mapTomlIpReputation(config.ipReputation); + toml.normalize(); + return toml; + } + + /** + * Maps a normalized {@link TomlSecretsConfig} into a legacy {@link GlobalConfig}. + * + *

The returned instance is fully initialized and normalized so that + * consumers do not need to call {@link GlobalConfig#normalize()} again, + * although doing so is harmless.

+ * + * @param toml the TOML DTO to map; must not be {@code null} + * @return a new {@link GlobalConfig} populated from the DTO + * @throws IllegalArgumentException if {@code toml} is {@code null} + */ + public static GlobalConfig toGlobalConfig(TomlSecretsConfig toml) { + if (toml == null) { + throw new IllegalArgumentException("toml must not be null"); + } + toml.normalize(); + + GlobalConfig global = new GlobalConfig(); + + // database + global.mongoConnectionString = blankToNull(toml.database.mongoConnectionString); + global.databaseName = blankToNull(toml.database.name); + global.isDataBaseReadOnly = toml.database.readOnly; + global.isDataBaseMigration = toml.database.migrationEnabled; + + // external links + global.discordUrl = toml.externalLinks.discordUrl; + global.githubUrl = toml.externalLinks.githubUrl; + global.donatelloUrl = toml.externalLinks.donatelloUrl; + global.weblateUrl = toml.externalLinks.weblateUrl; + global.discordRedVSBlueUrl = toml.externalLinks.discordRedVSBlueUrl; + + // moderation + global.minPlayTimeForVotekick = requireNonNull(toml.moderation, "moderation").votekick.minPlayTimeMinutes; + global.voteKickBanDurationMinutes = toml.moderation.votekick.banDurationMinutes; + global.voteDurationSeconds = toml.moderation.votekick.voteDurationSeconds; + + // chat + global.minPlayTimeForGlobalChat = requireNonNull(toml.chat, "chat").global.minPlayTimeMinutes; + + // maps + global.mapSwitchDelaySeconds = requireNonNull(toml.maps, "maps").voting.switchDelaySeconds; + + // pagination + global.eventsPerPage = toml.pagination.eventsPerPage; + global.mapsPerPage = toml.pagination.mapsPerPage; + global.commandsPerPage = toml.pagination.commandsPerPage; + global.privateMessagesPerPage = toml.pagination.privateMessagesPerPage; + + // messages + global.maxHistory = requireNonNull(toml.messages, "messages").history.maxHistory; + global.privateMessageMaxLength = toml.messages.privateMessages.maxLength; + global.privateMessageCooldownSeconds = toml.messages.privateMessages.cooldownSeconds; + global.privateMessageUnreadLimit = toml.messages.privateMessages.unreadLimit; + global.privateMessageBlockedLimit = toml.messages.privateMessages.blockedLimit; + + // translation providers + global.translationProviders = mapTranslationProviders( + requireNonNull(toml.translation, "translation").providers + ); + + // ip reputation provider + global.ipReputationProvider = mapIpReputationProvider( + requireNonNull(toml.ipReputation, "ipReputation").provider + ); + + return global; + } + + /** + * Maps a normalized legacy {@link GlobalConfig} into the structured + * {@link TomlSecretsConfig} DTO used for TOML persistence. + * + * @param global the legacy runtime global config; must not be {@code null} + * @return a new {@link TomlSecretsConfig} populated from the legacy model + * @throws IllegalArgumentException if {@code global} is {@code null} + */ + public static TomlSecretsConfig toTomlSecretsConfig(GlobalConfig global) { + if (global == null) { + throw new IllegalArgumentException("global must not be null"); + } + global.normalize(); + + TomlSecretsConfig toml = new TomlSecretsConfig(); + + toml.database.mongoConnectionString = nullToBlank(global.mongoConnectionString); + toml.database.name = nullToBlank(global.databaseName); + toml.database.readOnly = global.isDataBaseReadOnly; + toml.database.migrationEnabled = global.isDataBaseMigration; + + toml.externalLinks.discordUrl = global.discordUrl; + toml.externalLinks.githubUrl = global.githubUrl; + toml.externalLinks.donatelloUrl = global.donatelloUrl; + toml.externalLinks.weblateUrl = global.weblateUrl; + toml.externalLinks.discordRedVSBlueUrl = global.discordRedVSBlueUrl; + + toml.moderation.votekick.minPlayTimeMinutes = global.minPlayTimeForVotekick; + toml.moderation.votekick.banDurationMinutes = global.voteKickBanDurationMinutes; + toml.moderation.votekick.voteDurationSeconds = global.voteDurationSeconds; + + toml.chat.global.minPlayTimeMinutes = global.minPlayTimeForGlobalChat; + toml.maps.voting.switchDelaySeconds = global.mapSwitchDelaySeconds; + + toml.pagination.eventsPerPage = global.eventsPerPage; + toml.pagination.mapsPerPage = global.mapsPerPage; + toml.pagination.commandsPerPage = global.commandsPerPage; + toml.pagination.privateMessagesPerPage = global.privateMessagesPerPage; + + toml.messages.history.maxHistory = global.maxHistory; + toml.messages.privateMessages.maxLength = global.privateMessageMaxLength; + toml.messages.privateMessages.cooldownSeconds = global.privateMessageCooldownSeconds; + toml.messages.privateMessages.unreadLimit = global.privateMessageUnreadLimit; + toml.messages.privateMessages.blockedLimit = global.privateMessageBlockedLimit; + + toml.translation.providers = mapTomlTranslationProviders(global.translationProviders); + + toml.ipReputation.provider.baseUrl = global.ipReputationProvider.baseUrl; + toml.ipReputation.provider.timeoutSeconds = global.ipReputationProvider.timeoutSeconds; + toml.ipReputation.provider.maxRetries = global.ipReputationProvider.maxRetries; + toml.ipReputation.provider.rateLimitPerMinute = global.ipReputationProvider.rateLimitPerMinute; + + toml.normalize(); + return toml; + } + + // ------------------------------------------------------------------ + // Config helpers + // ------------------------------------------------------------------ + + private static Config.TranslationConfig mapTranslation(TomlXcoreConfig.TranslationConfig src) { + if (src == null) { + return new Config.TranslationConfig(); + } + + Config.TranslationConfig dst = new Config.TranslationConfig(); + dst.enabled = src.enabled; + dst.pipeline = src.pipeline == null ? new ArrayList<>(List.of("google")) : new ArrayList<>(src.pipeline); + dst.preserveOriginalMessageOnFailure = src.preserveOriginalMessageOnFailure; + dst.cache = mapTranslationCache(src.cache); + dst.metrics = mapTranslationMetrics(src.metrics); + dst.llm = mapLlmPolicy(src.llm); + return dst; + } + + private static TomlXcoreConfig.TranslationConfig mapTomlTranslation(Config.TranslationConfig src) { + if (src == null) { + return new TomlXcoreConfig.TranslationConfig(); + } + + TomlXcoreConfig.TranslationConfig dst = new TomlXcoreConfig.TranslationConfig(); + dst.enabled = src.enabled; + dst.pipeline = src.pipeline == null ? new ArrayList<>(List.of("google")) : new ArrayList<>(src.pipeline); + dst.preserveOriginalMessageOnFailure = src.preserveOriginalMessageOnFailure; + dst.cache = mapTomlTranslationCache(src.cache); + dst.metrics = mapTomlTranslationMetrics(src.metrics); + dst.llm = mapTomlLlmPolicy(src.llm); + return dst; + } + + private static Config.TranslationCacheConfig mapTranslationCache(TomlXcoreConfig.CacheConfig src) { + if (src == null) { + return new Config.TranslationCacheConfig(); + } + Config.TranslationCacheConfig dst = new Config.TranslationCacheConfig(); + dst.enabled = src.enabled; + dst.ttlSeconds = src.ttlSeconds; + dst.maxTextLength = src.maxTextLength; + return dst; + } + + private static Config.TranslationMetricsConfig mapTranslationMetrics(TomlXcoreConfig.MetricsConfig src) { + if (src == null) { + return new Config.TranslationMetricsConfig(); + } + Config.TranslationMetricsConfig dst = new Config.TranslationMetricsConfig(); + dst.enabled = src.enabled; + dst.minuteBucketsEnabled = src.minuteBucketsEnabled; + dst.minuteBucketTtlSeconds = src.minuteBucketTtlSeconds; + return dst; + } + + private static Config.LlmTranslationPolicyConfig mapLlmPolicy(TomlXcoreConfig.LlmConfig src) { + if (src == null) { + return new Config.LlmTranslationPolicyConfig(); + } + Config.LlmTranslationPolicyConfig dst = new Config.LlmTranslationPolicyConfig(); + dst.preserveFormattingTokens = src.preserveFormattingTokens; + dst.structuredOutputRequired = src.structuredOutputRequired; + dst.maxInputChars = src.maxInputChars; + dst.maxOutputChars = src.maxOutputChars; + dst.stripControlCharacters = src.stripControlCharacters; + return dst; + } + + private static Config.IpReputationConfig mapIpReputation(TomlXcoreConfig.IpReputationConfig src) { + if (src == null) { + return new Config.IpReputationConfig(); + } + Config.IpReputationConfig dst = new Config.IpReputationConfig(); + dst.enabled = src.enabled; + dst.blockProxy = src.blockProxy; + dst.blockVpn = src.blockVpn; + dst.blockTor = src.blockTor; + dst.blockHosting = src.blockHosting; + dst.cacheTtlSeconds = src.cacheTtlSeconds; + return dst; + } + + private static TomlXcoreConfig.CacheConfig mapTomlTranslationCache(Config.TranslationCacheConfig src) { + if (src == null) { + return new TomlXcoreConfig.CacheConfig(); + } + TomlXcoreConfig.CacheConfig dst = new TomlXcoreConfig.CacheConfig(); + dst.enabled = src.enabled; + dst.ttlSeconds = src.ttlSeconds; + dst.maxTextLength = src.maxTextLength; + return dst; + } + + private static TomlXcoreConfig.MetricsConfig mapTomlTranslationMetrics(Config.TranslationMetricsConfig src) { + if (src == null) { + return new TomlXcoreConfig.MetricsConfig(); + } + TomlXcoreConfig.MetricsConfig dst = new TomlXcoreConfig.MetricsConfig(); + dst.enabled = src.enabled; + dst.minuteBucketsEnabled = src.minuteBucketsEnabled; + dst.minuteBucketTtlSeconds = src.minuteBucketTtlSeconds; + return dst; + } + + private static TomlXcoreConfig.LlmConfig mapTomlLlmPolicy(Config.LlmTranslationPolicyConfig src) { + if (src == null) { + return new TomlXcoreConfig.LlmConfig(); + } + TomlXcoreConfig.LlmConfig dst = new TomlXcoreConfig.LlmConfig(); + dst.preserveFormattingTokens = src.preserveFormattingTokens; + dst.structuredOutputRequired = src.structuredOutputRequired; + dst.maxInputChars = src.maxInputChars; + dst.maxOutputChars = src.maxOutputChars; + dst.stripControlCharacters = src.stripControlCharacters; + return dst; + } + + private static TomlXcoreConfig.IpReputationConfig mapTomlIpReputation(Config.IpReputationConfig src) { + if (src == null) { + return new TomlXcoreConfig.IpReputationConfig(); + } + TomlXcoreConfig.IpReputationConfig dst = new TomlXcoreConfig.IpReputationConfig(); + dst.enabled = src.enabled; + dst.blockProxy = src.blockProxy; + dst.blockVpn = src.blockVpn; + dst.blockTor = src.blockTor; + dst.blockHosting = src.blockHosting; + dst.cacheTtlSeconds = src.cacheTtlSeconds; + return dst; + } + + // ------------------------------------------------------------------ + // GlobalConfig helpers + // ------------------------------------------------------------------ + + private static Map mapTranslationProviders( + Map src + ) { + if (src == null || src.isEmpty()) { + var defaults = new LinkedHashMap(); + defaults.put("google", new GlobalConfig.TranslationProviderConfig()); + return defaults; + } + + var dst = new LinkedHashMap(); + src.forEach((id, provider) -> dst.put(id, mapTranslationProvider(provider))); + return dst; + } + + private static GlobalConfig.TranslationProviderConfig mapTranslationProvider( + TomlSecretsConfig.TranslationSection.ProviderConfig src + ) { + if (src == null) { + return new GlobalConfig.TranslationProviderConfig(); + } + + GlobalConfig.TranslationProviderConfig dst = new GlobalConfig.TranslationProviderConfig(); + dst.type = src.type; + dst.enabled = src.enabled; + dst.apiKey = blankToNull(src.apiKey); + dst.baseUrl = src.baseUrl; + dst.model = src.model; + dst.apiMode = blankToNull(src.apiMode); + dst.organization = blankToNull(src.organization); + dst.project = blankToNull(src.project); + dst.timeoutSeconds = src.timeoutSeconds; + dst.maxRetries = src.maxRetries; + dst.temperature = src.temperature; + dst.supportedLanguages = copySet(src.supportedLanguages); + return dst; + } + + private static GlobalConfig.IpReputationProviderConfig mapIpReputationProvider( + TomlSecretsConfig.IpReputationSection.ProviderConfig src + ) { + if (src == null) { + return new GlobalConfig.IpReputationProviderConfig(); + } + + GlobalConfig.IpReputationProviderConfig dst = new GlobalConfig.IpReputationProviderConfig(); + dst.baseUrl = src.baseUrl; + dst.timeoutSeconds = src.timeoutSeconds; + dst.maxRetries = src.maxRetries; + dst.rateLimitPerMinute = src.rateLimitPerMinute; + return dst; + } + + private static Map mapTomlTranslationProviders( + Map src + ) { + if (src == null || src.isEmpty()) { + return defaultTomlTranslationProviders(); + } + + var dst = new LinkedHashMap(); + src.forEach((id, provider) -> dst.put(id, mapTomlTranslationProvider(provider))); + return dst; + } + + private static TomlSecretsConfig.TranslationSection.ProviderConfig mapTomlTranslationProvider( + GlobalConfig.TranslationProviderConfig src + ) { + if (src == null) { + return new TomlSecretsConfig.TranslationSection.ProviderConfig(); + } + + TomlSecretsConfig.TranslationSection.ProviderConfig dst = new TomlSecretsConfig.TranslationSection.ProviderConfig(); + dst.type = src.type; + dst.enabled = src.enabled; + dst.apiKey = nullToBlank(src.apiKey); + dst.baseUrl = src.baseUrl; + dst.model = src.model; + dst.apiMode = nullToBlank(src.apiMode); + dst.organization = nullToBlank(src.organization); + dst.project = nullToBlank(src.project); + dst.timeoutSeconds = src.timeoutSeconds; + dst.maxRetries = src.maxRetries; + dst.temperature = src.temperature; + dst.supportedLanguages = copyLinkedHashSet(src.supportedLanguages); + return dst; + } + + // ------------------------------------------------------------------ + // Shared utilities + // ------------------------------------------------------------------ + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private static String nullToBlank(String value) { + return value == null ? "" : value; + } + + private static T requireNonNull(T value, String name) { + if (value == null) { + throw new IllegalArgumentException(name + " must not be null after normalization"); + } + return value; + } + + private static Set copySet(Set src) { + return src == null ? new HashSet<>() : new HashSet<>(src); + } + + private static HashSet copyMutableSet(Set src) { + return src == null ? new HashSet<>() : new HashSet<>(src); + } + + private static LinkedHashSet copyLinkedHashSet(Set src) { + return src == null ? new LinkedHashSet<>() : new LinkedHashSet<>(src); + } + + private static Map defaultTomlTranslationProviders() { + var defaults = new LinkedHashMap(); + defaults.put("google", new TomlSecretsConfig.TranslationSection.ProviderConfig()); + return defaults; + } +} diff --git a/src/main/java/org/xcore/plugin/config/ConfigTomlTemplateWriter.java b/src/main/java/org/xcore/plugin/config/ConfigTomlTemplateWriter.java new file mode 100644 index 00000000..8b5cf31f --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ConfigTomlTemplateWriter.java @@ -0,0 +1,195 @@ +package org.xcore.plugin.config; + +import arc.files.Fi; + +/** + * Writes commented default TOML templates for {@code xcore.toml} and + * {@code secrets.toml}. + * + *

Templates are maintained as inline strings rather than generated from + * the typed DTOs because Jackson TOML writer output is not ideal for + * hand-maintained comments and section ordering.

+ * + *

All secret values are written as empty strings or safe defaults; + * no credentials are embedded in the templates.

+ */ +public final class ConfigTomlTemplateWriter { + + private ConfigTomlTemplateWriter() { + } + + /** + * Writes the default {@code xcore.toml} template to {@code target}. + * + * @param target the file to write + */ + public static void writeDefaultXcoreToml(Fi target) { + target.writeString(defaultXcoreTomlContent()); + } + + /** + * Writes the default {@code secrets.toml} template to {@code target}. + * + * @param target the file to write + */ + public static void writeDefaultSecretsToml(Fi target) { + target.writeString(defaultSecretsTomlContent()); + } + + /** + * Returns the full default content for {@code xcore.toml}. + * + * @return commented TOML string matching the target schema + */ + public static String defaultXcoreTomlContent() { + return """ + # XCore server-local configuration + # Restart required for changes to take effect. + version = 1 + + [server] + # Server identity used by transport and mode helpers. + name = "server" + # Public host/IP override (blank = auto-detect). + public_host_override = "" + player_limit = 30 + console_enabled = true + game_started_timer = true + + [paths] + # Directory for shared secrets.toml. Blank defaults to user home. + global_config_directory = "" + + [discord] + channel_id = 0 + + [transport.redis] + url = "redis://127.0.0.1:6379" + group_prefix = "xcore:cg" + consumer_name = "xcore-node" + + [transport.redis.reclaim] + enabled = true + min_idle_ms = 15000 + batch = 50 + + [transport.redis.dlq] + enabled = true + max_delivery_attempts = 3 + prefix = "xcore:dlq" + + [runtime] + disabled_commands = [] + disabled_features = [] + + [event_hub] + enabled = false + map_id = "" + + [translation] + enabled = true + pipeline = ["google"] + preserve_original_message_on_failure = true + + [translation.cache] + enabled = true + ttl_seconds = 1800 + max_text_length = 500 + + [translation.metrics] + enabled = true + minute_buckets_enabled = true + minute_bucket_ttl_seconds = 21600 + + [translation.llm] + preserve_formatting_tokens = true + structured_output_required = true + max_input_chars = 500 + max_output_chars = 1200 + strip_control_characters = true + + [ip_reputation] + enabled = false + block_proxy = true + block_vpn = true + block_tor = true + block_hosting = false + cache_ttl_seconds = 3600 + """; + } + + /** + * Returns the full default content for {@code secrets.toml}. + * + * @return commented TOML string matching the target schema + */ + public static String defaultSecretsTomlContent() { + return """ + # XCore shared secrets and global configuration + # Keep this file secure. Restart required for changes. + version = 1 + + [database] + # Required: MongoDB connection string. + mongo_connection_string = "" + # Required: MongoDB database name. + name = "" + read_only = false + migration_enabled = false + + [external_links] + discord_url = "https://discord.gg/RUMCCa9QAC" + github_url = "https://github.com/XCore-mindustry/" + donatello_url = "https://donatello.to/xcore" + weblate_url = "https://xcore.eradication.fun/" + discord_red_vs_blue_url = "https://discord.gg/UdnuFetcNt" + + [moderation.votekick] + min_play_time_minutes = 60 + ban_duration_minutes = 30 + vote_duration_seconds = 60.0 + + [chat.global] + min_play_time_minutes = 240 + + [maps.voting] + switch_delay_seconds = 10 + + [pagination] + events_per_page = 10 + maps_per_page = 10 + commands_per_page = 6 + private_messages_per_page = 10 + + [messages.history] + max_history = 16 + + [messages.private] + max_length = 300 + cooldown_seconds = 10 + unread_limit = 30 + blocked_limit = 100 + + [translation.providers.google] + type = "google" + enabled = true + # Provider API key (keep secret). + api_key = "" + base_url = "https://api.openai.com/v1" + model = "gpt-5.4" + api_mode = "" + organization = "" + project = "" + timeout_seconds = 15 + max_retries = 1 + temperature = 0.0 + supported_languages = [] + + [ip_reputation.provider] + base_url = "http://ip-api.com/json" + timeout_seconds = 10 + max_retries = 2 + rate_limit_per_minute = 45 + """; + } +} diff --git a/src/main/java/org/xcore/plugin/config/GlobalConfig.java b/src/main/java/org/xcore/plugin/config/GlobalConfig.java index a566ff78..7679d358 100644 --- a/src/main/java/org/xcore/plugin/config/GlobalConfig.java +++ b/src/main/java/org/xcore/plugin/config/GlobalConfig.java @@ -84,10 +84,10 @@ public void postInit(Fi globalConfigFile) { var errors = new ArrayList(); if (mongoConnectionString == null || mongoConnectionString.isBlank()) { - errors.add("mongo_connection_string"); + errors.add("database.mongo_connection_string"); } if (databaseName == null || databaseName.isBlank()) { - errors.add("database_name"); + errors.add("database.name"); } if (!errors.isEmpty()) { @@ -96,11 +96,10 @@ public void postInit(Fi globalConfigFile) { err(" Missing or invalid required fields:"); errors.forEach(key -> err(" - @", key)); err(""); - err(" Example configuration:"); - err(" {"); - err(" \"mongo_connection_string\": \"mongodb://localhost:27017\","); - err(" \"database_name\": \"xcore\""); - err(" }"); + err(" Example secrets.toml:"); + err(" [database]"); + err(" mongo_connection_string = \"mongodb://localhost:27017\""); + err(" name = \"xcore\""); err(""); err(" Fix @ and restart.", globalConfigFile.name()); err("==========================================="); diff --git a/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java b/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java new file mode 100644 index 00000000..152374e1 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java @@ -0,0 +1,220 @@ +package org.xcore.plugin.config; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Updates server-local config values using either legacy flat field names or + * TOML-oriented dotted paths, then maps the structured TOML view back into the + * legacy runtime {@link Config} model. + */ +public final class ServerLocalConfigPathEditor { + private static final Map PATH_BINDINGS = createPathBindings(); + + private final Gson prettyGson; + + public ServerLocalConfigPathEditor(Gson prettyGson) { + this.prettyGson = Objects.requireNonNull(prettyGson, "prettyGson must not be null"); + } + + public Config update(Config config, String fieldPath, String value) { + Objects.requireNonNull(config, "config must not be null"); + Objects.requireNonNull(fieldPath, "fieldPath must not be null"); + Objects.requireNonNull(value, "value must not be null"); + + PathBinding binding = PATH_BINDINGS.get(fieldPath.trim()); + if (binding == null) { + return null; + } + + TomlXcoreConfig toml = ConfigTomlMapper.toTomlXcoreConfig(config); + JsonObject root = JsonParser.parseString(prettyGson.toJson(toml)).getAsJsonObject(); + PathLocation target = resolvePath(root, binding.canonicalPath()); + if (target == null) { + return null; + } + + binding.valueType().apply(target.parent(), target.key(), value, prettyGson); + + TomlXcoreConfig updatedToml = prettyGson.fromJson(root, TomlXcoreConfig.class); + return ConfigTomlMapper.toConfig(updatedToml); + } + + public static IllegalArgumentException invalidValue(String fieldPath, String expected, String value, Throwable cause) { + return new IllegalArgumentException( + "Invalid value '" + value + "' for '" + fieldPath + "' (expected " + expected + ").", + cause + ); + } + + public static IllegalArgumentException invalidValue(String fieldPath, String expected, String value) { + return new IllegalArgumentException( + "Invalid value '" + value + "' for '" + fieldPath + "' (expected " + expected + ")." + ); + } + + private static PathLocation resolvePath(JsonObject root, String path) { + JsonObject current = root; + String[] segments = path.split("\\."); + for (int i = 0; i < segments.length - 1; i++) { + String segment = segments[i]; + if (!current.has(segment) || !current.get(segment).isJsonObject()) { + return null; + } + current = current.getAsJsonObject(segment); + } + + String key = segments[segments.length - 1]; + if (!current.has(key)) { + return null; + } + return new PathLocation(current, key); + } + + private static Map createPathBindings() { + Map bindings = new LinkedHashMap<>(); + + bind(bindings, "server.name", ValueType.STRING, "server"); + bind(bindings, "server.public_host_override", ValueType.STRING, "public_host_override", "publicHostOverride"); + bind(bindings, "server.player_limit", ValueType.INT, "player_limit", "playerLimit"); + bind(bindings, "server.console_enabled", ValueType.BOOLEAN, "console_enabled", "consoleEnabled"); + bind(bindings, "server.game_started_timer", ValueType.BOOLEAN, "game_started_timer", "gameStartedTimer"); + + bind(bindings, "paths.global_config_directory", ValueType.STRING, "global_config_directory", "globalConfigDirectory"); + bind(bindings, "discord.channel_id", ValueType.LONG, "discord_channel_id", "discordChannelId"); + + bind(bindings, "transport.redis.url", ValueType.STRING, "redis_url", "redisUrl"); + bind(bindings, "transport.redis.group_prefix", ValueType.STRING, "redis_group_prefix", "redisGroupPrefix"); + bind(bindings, "transport.redis.consumer_name", ValueType.STRING, "redis_consumer_name", "redisConsumerName"); + bind(bindings, "transport.redis.reclaim.enabled", ValueType.BOOLEAN, "redis_reclaim_enabled", "redisReclaimEnabled"); + bind(bindings, "transport.redis.reclaim.min_idle_ms", ValueType.LONG, "redis_reclaim_min_idle_ms", "redisReclaimMinIdleMs"); + bind(bindings, "transport.redis.reclaim.batch", ValueType.INT, "redis_reclaim_batch", "redisReclaimBatch"); + bind(bindings, "transport.redis.dlq.enabled", ValueType.BOOLEAN, "redis_dlq_enabled", "redisDlqEnabled"); + bind(bindings, "transport.redis.dlq.max_delivery_attempts", ValueType.INT, "redis_max_delivery_attempts", "redisMaxDeliveryAttempts"); + bind(bindings, "transport.redis.dlq.prefix", ValueType.STRING, "redis_dlq_prefix", "redisDlqPrefix"); + + bind(bindings, "event_hub.enabled", ValueType.BOOLEAN, "is_event_hub_map", "isEventHubMap"); + bind(bindings, "event_hub.map_id", ValueType.STRING, "event_hub_map_id", "eventHubMapID"); + + bind(bindings, "translation.enabled", ValueType.BOOLEAN); + bind(bindings, "translation.pipeline", ValueType.STRING_LIST); + bind(bindings, "translation.preserve_original_message_on_failure", ValueType.BOOLEAN); + bind(bindings, "translation.cache.enabled", ValueType.BOOLEAN); + bind(bindings, "translation.cache.ttl_seconds", ValueType.INT); + bind(bindings, "translation.cache.max_text_length", ValueType.INT); + bind(bindings, "translation.metrics.enabled", ValueType.BOOLEAN); + bind(bindings, "translation.metrics.minute_buckets_enabled", ValueType.BOOLEAN); + bind(bindings, "translation.metrics.minute_bucket_ttl_seconds", ValueType.INT); + bind(bindings, "translation.llm.preserve_formatting_tokens", ValueType.BOOLEAN); + bind(bindings, "translation.llm.structured_output_required", ValueType.BOOLEAN); + bind(bindings, "translation.llm.max_input_chars", ValueType.INT); + bind(bindings, "translation.llm.max_output_chars", ValueType.INT); + bind(bindings, "translation.llm.strip_control_characters", ValueType.BOOLEAN); + + bind(bindings, "ip_reputation.enabled", ValueType.BOOLEAN); + bind(bindings, "ip_reputation.block_proxy", ValueType.BOOLEAN); + bind(bindings, "ip_reputation.block_vpn", ValueType.BOOLEAN); + bind(bindings, "ip_reputation.block_tor", ValueType.BOOLEAN); + bind(bindings, "ip_reputation.block_hosting", ValueType.BOOLEAN); + bind(bindings, "ip_reputation.cache_ttl_seconds", ValueType.INT); + + return bindings; + } + + private static void bind(Map bindings, String canonicalPath, ValueType type, String... aliases) { + PathBinding binding = new PathBinding(canonicalPath, type); + bindings.put(canonicalPath, binding); + for (String alias : aliases) { + bindings.put(alias, binding); + } + } + + private record PathBinding(String canonicalPath, ValueType valueType) { + } + + private record PathLocation(JsonObject parent, String key) { + } + + private enum ValueType { + STRING { + @Override + void apply(JsonObject parent, String key, String value, Gson gson) { + parent.addProperty(key, value); + } + }, + BOOLEAN { + @Override + void apply(JsonObject parent, String key, String value, Gson gson) { + String trimmed = value.trim(); + if ("true".equalsIgnoreCase(trimmed) || "false".equalsIgnoreCase(trimmed)) { + parent.addProperty(key, Boolean.parseBoolean(trimmed)); + return; + } + + throw invalidValue(key, "true or false", value); + } + }, + LONG { + @Override + void apply(JsonObject parent, String key, String value, Gson gson) { + try { + parent.addProperty(key, Long.parseLong(value.trim())); + } catch (NumberFormatException e) { + throw invalidValue(key, "integer number", value, e); + } + } + }, + INT { + @Override + void apply(JsonObject parent, String key, String value, Gson gson) { + try { + parent.addProperty(key, Integer.parseInt(value.trim())); + } catch (NumberFormatException e) { + throw invalidValue(key, "integer number", value, e); + } + } + }, + STRING_LIST { + @Override + void apply(JsonObject parent, String key, String value, Gson gson) { + parent.add(key, parseStringList(value, gson)); + } + }; + + abstract void apply(JsonObject parent, String key, String value, Gson gson); + + private static JsonArray parseStringList(String value, Gson gson) { + String trimmed = value.trim(); + if (trimmed.startsWith("[")) { + try { + String[] values = gson.fromJson(trimmed, String[].class); + JsonArray array = new JsonArray(); + if (values != null) { + Arrays.stream(values) + .filter(Objects::nonNull) + .map(String::trim) + .filter(entry -> !entry.isEmpty()) + .forEach(array::add); + } + return array; + } catch (RuntimeException e) { + throw invalidValue("translation.pipeline", "a comma-separated list or JSON string array", value, e); + } + } + + JsonArray array = new JsonArray(); + Arrays.stream(value.split(",")) + .map(String::trim) + .filter(entry -> !entry.isEmpty()) + .forEach(array::add); + return array; + } + } +} diff --git a/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java new file mode 100644 index 00000000..0c1a4f8f --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java @@ -0,0 +1,33 @@ +package org.xcore.plugin.config; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.dataformat.toml.TomlMapper; + +import java.io.IOException; +import java.util.Objects; + +/** + * Renders the server-local runtime {@link Config} as a TOML-shaped view for + * operator-facing inspection commands such as {@code xconfig}. + */ +public final class ServerLocalConfigTomlRenderer { + + public String render(Config config) { + Objects.requireNonNull(config, "config must not be null"); + + TomlXcoreConfig toml = ConfigTomlMapper.toTomlXcoreConfig(config); + try { + return createTomlMapper().writeValueAsString(toml).trim(); + } catch (IOException e) { + throw new IllegalStateException("Failed to render server-local config as TOML: " + e.getMessage(), e); + } + } + + private static TomlMapper createTomlMapper() { + return TomlMapper.builder() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .build(); + } +} diff --git a/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java new file mode 100644 index 00000000..b60c7f09 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java @@ -0,0 +1,77 @@ +package org.xcore.plugin.config; + +import arc.files.Fi; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.dataformat.toml.TomlMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.Objects; + +/** + * Focused helper that persists the server-local {@link Config} runtime model + * back to {@code xcore.toml}. + * + *

This helper keeps TOML write logic out of controllers and does not + * touch global/secrets persistence. It uses the existing + * {@link ConfigTomlMapper#toTomlXcoreConfig(Config)} mapping so that + * written values match the startup TOML schema.

+ */ +public final class ServerLocalConfigTomlStore { + private final Fi tomlFile; + + public ServerLocalConfigTomlStore(Fi tomlFile) { + this.tomlFile = Objects.requireNonNull(tomlFile, "tomlFile must not be null"); + } + + /** + * Writes the given {@link Config} to the configured {@code xcore.toml} file. + * + *

The config is normalized and mapped to {@link TomlXcoreConfig} before + * writing so the output matches the startup TOML schema.

+ * + * @param config the runtime config to persist; must not be {@code null} + * @throws NullPointerException if {@code config} is {@code null} + * @throws IllegalStateException if writing fails + */ + public void write(Config config) { + Objects.requireNonNull(config, "config must not be null"); + + TomlXcoreConfig toml = ConfigTomlMapper.toTomlXcoreConfig(config); + writeToml(tomlFile, toml); + } + + /** + * Returns the target TOML file handle. + * + * @return the {@code xcore.toml} file this store writes to + */ + public Fi file() { + return tomlFile; + } + + private static void writeToml(Fi file, Object config) { + try { + java.io.File javaFile = file.file(); + if (javaFile != null) { + var parent = javaFile.toPath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + } + String tomlString = createTomlMapper().writeValueAsString(config); + file.writeString(tomlString); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to write TOML to " + file.absolutePath() + ": " + e.getMessage(), e); + } + } + + private static TomlMapper createTomlMapper() { + return TomlMapper.builder() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .build(); + } +} diff --git a/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java b/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java new file mode 100644 index 00000000..97892144 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java @@ -0,0 +1,245 @@ +package org.xcore.plugin.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public class TomlSecretsConfig { + public int version = 1; + + public DatabaseConfig database = new DatabaseConfig(); + public ExternalLinksConfig externalLinks = new ExternalLinksConfig(); + public ModerationConfig moderation = new ModerationConfig(); + public ChatConfig chat = new ChatConfig(); + public MapsConfig maps = new MapsConfig(); + public PaginationConfig pagination = new PaginationConfig(); + public MessagesConfig messages = new MessagesConfig(); + public TranslationSection translation = new TranslationSection(); + public IpReputationSection ipReputation = new IpReputationSection(); + + public void normalize() { + if (database == null) { + database = new DatabaseConfig(); + } + if (externalLinks == null) { + externalLinks = new ExternalLinksConfig(); + } + if (moderation == null) { + moderation = new ModerationConfig(); + } + moderation.normalize(); + if (chat == null) { + chat = new ChatConfig(); + } + chat.normalize(); + if (maps == null) { + maps = new MapsConfig(); + } + maps.normalize(); + if (pagination == null) { + pagination = new PaginationConfig(); + } + if (messages == null) { + messages = new MessagesConfig(); + } + messages.normalize(); + if (translation == null) { + translation = new TranslationSection(); + } + translation.normalize(); + if (ipReputation == null) { + ipReputation = new IpReputationSection(); + } + ipReputation.normalize(); + } + + public static class DatabaseConfig { + public String mongoConnectionString = ""; + public String name = ""; + public boolean readOnly = false; + public boolean migrationEnabled = false; + } + + public static class ExternalLinksConfig { + public String discordUrl = "https://discord.gg/RUMCCa9QAC"; + public String githubUrl = "https://github.com/XCore-mindustry/"; + public String donatelloUrl = "https://donatello.to/xcore"; + public String weblateUrl = "https://xcore.eradication.fun/"; + @JsonProperty("discord_red_vs_blue_url") + public String discordRedVSBlueUrl = "https://discord.gg/UdnuFetcNt"; + } + + public static class ModerationConfig { + public VotekickConfig votekick = new VotekickConfig(); + + public void normalize() { + if (votekick == null) { + votekick = new VotekickConfig(); + } + } + + public static class VotekickConfig { + public int minPlayTimeMinutes = 60; + public int banDurationMinutes = 30; + public float voteDurationSeconds = 60.0f; + } + } + + public static class ChatConfig { + public GlobalConfig global = new GlobalConfig(); + + public void normalize() { + if (global == null) { + global = new GlobalConfig(); + } + } + + public static class GlobalConfig { + public int minPlayTimeMinutes = 240; + } + } + + public static class MapsConfig { + public VotingConfig voting = new VotingConfig(); + + public void normalize() { + if (voting == null) { + voting = new VotingConfig(); + } + } + + public static class VotingConfig { + public int switchDelaySeconds = 10; + } + } + + public static class PaginationConfig { + public int eventsPerPage = 10; + public int mapsPerPage = 10; + public int commandsPerPage = 6; + public int privateMessagesPerPage = 10; + } + + public static class MessagesConfig { + public HistoryConfig history = new HistoryConfig(); + + @JsonProperty("private") + public PrivateConfig privateMessages = new PrivateConfig(); + + public void normalize() { + if (history == null) { + history = new HistoryConfig(); + } + if (privateMessages == null) { + privateMessages = new PrivateConfig(); + } + } + + public static class HistoryConfig { + public int maxHistory = 16; + } + + public static class PrivateConfig { + public int maxLength = 300; + public int cooldownSeconds = 10; + public int unreadLimit = 30; + public int blockedLimit = 100; + } + } + + public static class TranslationSection { + public Map providers = defaultProviders(); + + public void normalize() { + if (providers == null || providers.isEmpty()) { + providers = defaultProviders(); + } + providers.replaceAll((id, config) -> { + ProviderConfig normalized = config == null ? new ProviderConfig() : config; + normalized.normalize(); + return normalized; + }); + } + + private static Map defaultProviders() { + var map = new LinkedHashMap(); + map.put("google", new ProviderConfig()); + return map; + } + + public static class ProviderConfig { + public String type = "google"; + public boolean enabled = true; + public String apiKey = ""; + public String baseUrl = "https://api.openai.com/v1"; + public String model = "gpt-5.4"; + public String apiMode = ""; + public String organization = ""; + public String project = ""; + public int timeoutSeconds = 15; + public int maxRetries = 1; + public double temperature = 0.0; + public Set supportedLanguages = new LinkedHashSet<>(); + + public void normalize() { + if (type == null || type.isBlank()) { + type = "google"; + } + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = "https://api.openai.com/v1"; + } + if (model == null || model.isBlank()) { + model = "gpt-5.4"; + } + if (apiMode != null && !apiMode.isBlank()) { + apiMode = apiMode.trim().toLowerCase(); + } + if (timeoutSeconds <= 0) { + timeoutSeconds = 15; + } + if (maxRetries < 0) { + maxRetries = 1; + } + if (supportedLanguages == null) { + supportedLanguages = new LinkedHashSet<>(); + } + } + } + } + + public static class IpReputationSection { + public ProviderConfig provider = new ProviderConfig(); + + public void normalize() { + if (provider == null) { + provider = new ProviderConfig(); + } else { + provider.normalize(); + } + } + + public static class ProviderConfig { + public String baseUrl = "http://ip-api.com/json"; + public int timeoutSeconds = 10; + public int maxRetries = 2; + public int rateLimitPerMinute = 45; + + public void normalize() { + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = "http://ip-api.com/json"; + } + if (timeoutSeconds <= 0) { + timeoutSeconds = 10; + } + if (maxRetries < 0) { + maxRetries = 2; + } + if (rateLimitPerMinute <= 0) { + rateLimitPerMinute = 45; + } + } + } + } +} diff --git a/src/main/java/org/xcore/plugin/config/TomlXcoreConfig.java b/src/main/java/org/xcore/plugin/config/TomlXcoreConfig.java new file mode 100644 index 00000000..34ff66d7 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/TomlXcoreConfig.java @@ -0,0 +1,267 @@ +package org.xcore.plugin.config; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Pure-data DTO for the server-local {@code xcore.toml} configuration. + * Mirrors the TOML section structure and preserves default values that match + * the legacy {@link Config} runtime model for the first adapter slice. + * + *

Normalization repairs null nested objects and collections, converts blank + * optional strings back to {@code null} where the legacy API expects it, and + * enforces positive numeric defaults.

+ */ +public class TomlXcoreConfig { + + public int version = 1; + public ServerConfig server = new ServerConfig(); + public PathsConfig paths = new PathsConfig(); + public DiscordConfig discord = new DiscordConfig(); + public TransportConfig transport = new TransportConfig(); + public RuntimeConfig runtime = new RuntimeConfig(); + public EventHubConfig eventHub = new EventHubConfig(); + public TranslationConfig translation = new TranslationConfig(); + public IpReputationConfig ipReputation = new IpReputationConfig(); + + public void normalize() { + if (server == null) { + server = new ServerConfig(); + } + if (paths == null) { + paths = new PathsConfig(); + } + if (discord == null) { + discord = new DiscordConfig(); + } + if (transport == null) { + transport = new TransportConfig(); + } + if (runtime == null) { + runtime = new RuntimeConfig(); + } + if (eventHub == null) { + eventHub = new EventHubConfig(); + } + if (translation == null) { + translation = new TranslationConfig(); + } + if (ipReputation == null) { + ipReputation = new IpReputationConfig(); + } + + server.normalize(); + paths.normalize(); + transport.normalize(); + runtime.normalize(); + translation.normalize(); + ipReputation.normalize(); + } + + public static class ServerConfig { + public String name = "server"; + public String publicHostOverride = ""; + public int playerLimit = 30; + public boolean consoleEnabled = true; + public boolean gameStartedTimer = true; + + public void normalize() { + if (name == null || name.isBlank()) { + name = "server"; + } + if (publicHostOverride != null && publicHostOverride.isBlank()) { + publicHostOverride = null; + } + } + } + + public static class PathsConfig { + public String globalConfigDirectory = ""; + + public void normalize() { + if (globalConfigDirectory != null && globalConfigDirectory.isBlank()) { + globalConfigDirectory = null; + } + } + } + + public static class DiscordConfig { + public long channelId = 0L; + } + + public static class TransportConfig { + public RedisConfig redis = new RedisConfig(); + + public void normalize() { + if (redis == null) { + redis = new RedisConfig(); + } + redis.normalize(); + } + } + + public static class RedisConfig { + public String url = "redis://127.0.0.1:6379"; + public String groupPrefix = "xcore:cg"; + public String consumerName = "xcore-node"; + public ReclaimConfig reclaim = new ReclaimConfig(); + public DlqConfig dlq = new DlqConfig(); + + public void normalize() { + if (url == null || url.isBlank()) { + url = "redis://127.0.0.1:6379"; + } + if (groupPrefix == null || groupPrefix.isBlank()) { + groupPrefix = "xcore:cg"; + } + if (consumerName == null || consumerName.isBlank()) { + consumerName = "xcore-node"; + } + if (reclaim == null) { + reclaim = new ReclaimConfig(); + } + if (dlq == null) { + dlq = new DlqConfig(); + } + reclaim.normalize(); + dlq.normalize(); + } + } + + public static class ReclaimConfig { + public boolean enabled = true; + public long minIdleMs = 15000; + public int batch = 50; + + public void normalize() { + if (minIdleMs < 0) { + minIdleMs = 15000; + } + if (batch <= 0) { + batch = 50; + } + } + } + + public static class DlqConfig { + public boolean enabled = true; + public int maxDeliveryAttempts = 3; + public String prefix = "xcore:dlq"; + + public void normalize() { + if (maxDeliveryAttempts <= 0) { + maxDeliveryAttempts = 3; + } + if (prefix == null || prefix.isBlank()) { + prefix = "xcore:dlq"; + } + } + } + + public static class RuntimeConfig { + public Set disabledCommands = new HashSet<>(); + public Set disabledFeatures = new HashSet<>(); + + public void normalize() { + if (disabledCommands == null) { + disabledCommands = new HashSet<>(); + } + if (disabledFeatures == null) { + disabledFeatures = new HashSet<>(); + } + } + } + + public static class EventHubConfig { + public boolean enabled = false; + public String mapId = ""; + } + + public static class TranslationConfig { + public boolean enabled = true; + public List pipeline = new ArrayList<>(List.of("google")); + public boolean preserveOriginalMessageOnFailure = true; + public CacheConfig cache = new CacheConfig(); + public MetricsConfig metrics = new MetricsConfig(); + public LlmConfig llm = new LlmConfig(); + + public void normalize() { + if (pipeline == null || pipeline.isEmpty()) { + pipeline = new ArrayList<>(List.of("google")); + } + if (cache == null) { + cache = new CacheConfig(); + } + if (metrics == null) { + metrics = new MetricsConfig(); + } + if (llm == null) { + llm = new LlmConfig(); + } + cache.normalize(); + metrics.normalize(); + llm.normalize(); + } + } + + public static class CacheConfig { + public boolean enabled = true; + public int ttlSeconds = 1800; + public int maxTextLength = 500; + + public void normalize() { + if (ttlSeconds <= 0) { + ttlSeconds = 1800; + } + if (maxTextLength <= 0) { + maxTextLength = 500; + } + } + } + + public static class MetricsConfig { + public boolean enabled = true; + public boolean minuteBucketsEnabled = true; + public int minuteBucketTtlSeconds = 21600; + + public void normalize() { + if (minuteBucketTtlSeconds <= 0) { + minuteBucketTtlSeconds = 21600; + } + } + } + + public static class LlmConfig { + public boolean preserveFormattingTokens = true; + public boolean structuredOutputRequired = true; + public int maxInputChars = 500; + public int maxOutputChars = 1200; + public boolean stripControlCharacters = true; + + public void normalize() { + if (maxInputChars <= 0) { + maxInputChars = 500; + } + if (maxOutputChars <= 0) { + maxOutputChars = 1200; + } + } + } + + public static class IpReputationConfig { + public boolean enabled = false; + public boolean blockProxy = true; + public boolean blockVpn = true; + public boolean blockTor = true; + public boolean blockHosting = false; + public int cacheTtlSeconds = 3600; + + public void normalize() { + if (cacheTtlSeconds <= 0) { + cacheTtlSeconds = 3600; + } + } + } +} diff --git a/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java index cb99770f..c24a5b1e 100644 --- a/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java +++ b/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java @@ -1,6 +1,5 @@ package org.xcore.plugin.command.controller.server; -import arc.files.Fi; import com.google.gson.Gson; import org.bson.types.ObjectId; import org.junit.jupiter.api.DisplayName; @@ -8,28 +7,235 @@ import org.mockito.ArgumentCaptor; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.SerializationFactory; +import org.xcore.plugin.config.ServerLocalConfigPathEditor; +import org.xcore.plugin.config.ServerLocalConfigTomlRenderer; +import org.xcore.plugin.config.ServerLocalConfigTomlStore; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.FindService; import org.xcore.plugin.service.TopMenuCacheService; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class DataControllerTest { + @Test + @DisplayName("xconfigEdit updates in-memory config and persists through TOML store") + void xconfigEdit_updatesConfigAndPersistsThroughTomlStore() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "playerLimit", "64"); + + var configCaptor = ArgumentCaptor.forClass(Config.class); + verify(tomlStore).write(configCaptor.capture()); + assertThat(configCaptor.getValue().playerLimit).isEqualTo(64); + } + + @Test + @DisplayName("xconfigEdit supports nested TOML-style paths") + void xconfigEdit_supportsNestedTomlPaths() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "transport.redis.url", "redis://example:6379"); + + var configCaptor = ArgumentCaptor.forClass(Config.class); + verify(tomlStore).write(configCaptor.capture()); + assertThat(configCaptor.getValue().redisUrl).isEqualTo("redis://example:6379"); + } + + @Test + @DisplayName("xconfigEdit supports comma-separated translation pipeline updates") + void xconfigEdit_supportsCommaSeparatedTranslationPipeline() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "translation.pipeline", "google, openai, deepl"); + + var configCaptor = ArgumentCaptor.forClass(Config.class); + verify(tomlStore).write(configCaptor.capture()); + assertThat(configCaptor.getValue().translation.pipeline) + .containsExactly("google", "openai", "deepl"); + } + + @Test + @DisplayName("xconfigEdit supports array-style translation pipeline updates") + void xconfigEdit_supportsJsonArrayTranslationPipeline() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "translation.pipeline", "[\"google\", \"openai\"]"); + + var configCaptor = ArgumentCaptor.forClass(Config.class); + verify(tomlStore).write(configCaptor.capture()); + assertThat(configCaptor.getValue().translation.pipeline) + .containsExactly("google", "openai"); + } + + @Test + @DisplayName("xconfigEdit does not persist when field is missing") + void xconfigEdit_doesNotPersistWhenFieldIsMissing() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "missing_field", "value"); + + verify(tomlStore, never()).write(any(Config.class)); + assertThat(config.playerLimit).isEqualTo(30); + } + + @Test + @DisplayName("xconfigEdit does not persist when integer value is invalid") + void xconfigEdit_doesNotPersistWhenIntegerValueIsInvalid() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "playerLimit", "not-a-number"); + + verify(tomlStore, never()).write(any(Config.class)); + assertThat(config.playerLimit).isEqualTo(30); + } + + @Test + @DisplayName("xconfigEdit does not persist when boolean value is invalid") + void xconfigEdit_doesNotPersistWhenBooleanValueIsInvalid() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "consoleEnabled", "maybe"); + + verify(tomlStore, never()).write(any(Config.class)); + assertThat(config.consoleEnabled).isTrue(); + } + + @Test + @DisplayName("xconfigEdit does not persist when translation pipeline array is malformed") + void xconfigEdit_doesNotPersistWhenTranslationPipelineArrayIsMalformed() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "translation.pipeline", "[\"google\","); + + verify(tomlStore, never()).write(any(Config.class)); + assertThat(config.translation.pipeline).containsExactly("google"); + } + + @Test + @DisplayName("xconfigEdit rejects dedicated disabled command paths") + void xconfigEdit_rejectsDedicatedDisabledCommandPaths() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = mock(ServerLocalConfigPathEditor.class); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "runtime.disabled_commands", "[\"help\"]"); + + verify(tomlStore, never()).write(any(Config.class)); + verify(pathEditor, never()).update(any(Config.class), any(String.class), any(String.class)); + assertThat(config.disabledCommands).isEmpty(); + } + + @Test + @DisplayName("xconfigEdit rejects dedicated disabled feature paths") + void xconfigEdit_rejectsDedicatedDisabledFeaturePaths() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = mock(ServerLocalConfigPathEditor.class); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigEdit(sender, "disabledFeatures", "[\"rtv\"]"); + + verify(tomlStore, never()).write(any(Config.class)); + verify(pathEditor, never()).update(any(Config.class), any(String.class), any(String.class)); + assertThat(config.disabledFeatures).isEmpty(); + } + @Test @DisplayName("editData preserves player _id when saving updated payload") void editDataPreservesMongoId() { var repository = mock(PlayerDataRepository.class); var topMenuCacheService = mock(TopMenuCacheService.class); - var configFile = mock(Fi.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); var config = new Config(); var gson = new Gson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); + var pathEditor = mock(ServerLocalConfigPathEditor.class); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); var player = new PlayerData("u-1", true); player.pid = 0; @@ -40,7 +246,7 @@ void editDataPreservesMongoId() { when(find.playerData("#0")).thenReturn(player); when(repository.save(org.mockito.ArgumentMatchers.any(PlayerData.class))).thenReturn(true); - var controller = new DataController(repository, configFile, config, gson, find, topMenuCacheService); + var controller = new DataController(repository, config, gson, find, topMenuCacheService, pathEditor, tomlRenderer, tomlStore); controller.editData(sender, "#0", "pvpRating", "0"); var captor = ArgumentCaptor.forClass(PlayerData.class); @@ -52,4 +258,32 @@ void editDataPreservesMongoId() { assertThat(saved.uuid).isEqualTo("u-1"); verify(topMenuCacheService).invalidateAll(); } + + @Test + @DisplayName("xconfigShow renders TOML-shaped server-local config") + void xconfigShow_rendersTomlShapedServerLocalConfig() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new Config(); + config.server = "alpha"; + config.playerLimit = 64; + config.redisUrl = "redis://example:6379"; + var gson = new SerializationFactory().prettyGson(); + var find = mock(FindService.class); + var sender = mock(XCoreSender.class); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = new ServerLocalConfigTomlRenderer(); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + + assertThat(tomlRenderer.render(config)) + .contains("version = 1") + .contains("server.name = 'alpha'") + .contains("server.player_limit = 64") + .contains("transport.redis.url = 'redis://example:6379'"); + + controller.xconfigShow(sender); + + verify(tomlStore, never()).write(any(Config.class)); + } } diff --git a/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java index e6c07940..666319e6 100644 --- a/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java +++ b/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java @@ -7,6 +7,7 @@ import org.mockito.ArgumentCaptor; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.ServerLocalConfigTomlStore; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; @@ -35,6 +36,7 @@ void gcmdParsesCommaSeparatedTargets() { var auditService = mock(MapIdentityAuditService.class); var config = new Config(); var configFile = mock(Fi.class); + var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); var sender = mock(XCoreSender.class); @@ -45,7 +47,7 @@ void gcmdParsesCommaSeparatedTargets() { sessionService, auditService, config, - configFile, + tomlStore, gson ); @@ -70,6 +72,7 @@ void gcmdFallsBackToAllServers() { var auditService = mock(MapIdentityAuditService.class); var config = new Config(); var configFile = mock(Fi.class); + var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); var sender = mock(XCoreSender.class); @@ -80,7 +83,7 @@ void gcmdFallsBackToAllServers() { sessionService, auditService, config, - configFile, + tomlStore, gson ); @@ -105,6 +108,7 @@ void gcmdPreservesExclusionMode() { var auditService = mock(MapIdentityAuditService.class); var config = new Config(); var configFile = mock(Fi.class); + var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); var sender = mock(XCoreSender.class); @@ -115,7 +119,7 @@ void gcmdPreservesExclusionMode() { sessionService, auditService, config, - configFile, + tomlStore, gson ); @@ -140,6 +144,7 @@ void disableCmd_normalizesCommandAndPersistsConfig() { var auditService = mock(MapIdentityAuditService.class); var config = new Config(); var configFile = mock(Fi.class); + var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); var sender = mock(XCoreSender.class); @@ -150,7 +155,7 @@ void disableCmd_normalizesCommandAndPersistsConfig() { sessionService, auditService, config, - configFile, + tomlStore, gson ); @@ -170,6 +175,7 @@ void disableCmd_doesNotPersistProtectedCommands() { var auditService = mock(MapIdentityAuditService.class); var config = new Config(); var configFile = mock(Fi.class); + var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); var sender = mock(XCoreSender.class); @@ -180,7 +186,7 @@ void disableCmd_doesNotPersistProtectedCommands() { sessionService, auditService, config, - configFile, + tomlStore, gson ); @@ -201,6 +207,7 @@ void enableFeature_removesDisabledFeatureAndPersistsConfig() { var config = new Config(); config.disabledFeatures.add("rtv"); var configFile = mock(Fi.class); + var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); var sender = mock(XCoreSender.class); @@ -211,7 +218,7 @@ void enableFeature_removesDisabledFeatureAndPersistsConfig() { sessionService, auditService, config, - configFile, + tomlStore, gson ); @@ -232,6 +239,7 @@ void deleteBots_invalidatesTopCacheWhenPlayersAreRemoved() { var topMenuCacheService = mock(TopMenuCacheService.class); var config = new Config(); var configFile = mock(Fi.class); + var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); var sender = mock(XCoreSender.class); @@ -245,7 +253,7 @@ void deleteBots_invalidatesTopCacheWhenPlayersAreRemoved() { auditService, topMenuCacheService, config, - configFile, + tomlStore, gson ); diff --git a/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java b/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java index e5b0668a..dfbb560e 100644 --- a/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java +++ b/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDateTime; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -31,13 +32,15 @@ static void setUpDataDirectory() { } @BeforeEach - void cleanConfigFile() throws IOException { + void cleanConfigFiles() throws IOException { Files.deleteIfExists(tempDir.resolve("xcconfig.json")); + Files.deleteIfExists(tempDir.resolve("xcore.toml")); + ConfigTomlLoader.resetBackupTimestampSupplier(); } @Test - @DisplayName("config creates missing xcconfig file with normalized defaults") - void config_createsMissingXcconfigFileWithNormalizedDefaults() { + @DisplayName("config creates missing xcore.toml from defaults and loads normalized Config") + void config_createsMissingXcoreTomlFromDefaultsAndLoadsNormalizedConfig() { // Arrange ConfigFactory factory = new ConfigFactory(); @@ -51,40 +54,74 @@ void config_createsMissingXcconfigFileWithNormalizedDefaults() { assertThat(config.translation).isNotNull(); assertThat(config.translation.pipeline).containsExactly("google"); - Path configPath = tempDir.resolve("xcconfig.json"); - assertThat(configPath).exists(); - assertThat(read(configPath)).contains("\"server\": \"server\""); + Path tomlPath = tempDir.resolve("xcore.toml"); + assertThat(tomlPath).exists(); + assertThat(read(tomlPath)).contains("name = \"server\""); } @Test - @DisplayName("config normalizes existing xcconfig file when nullable sections are missing") - void config_normalizesExistingXcconfigFileWhenNullableSectionsAreMissing() { + @DisplayName("config loads xcore.toml when both TOML and legacy JSON exist") + void config_tomlTakesPrecedenceOverLegacyJson() throws IOException { // Arrange - Path configPath = tempDir.resolve("xcconfig.json"); - write(configPath, """ + Path tomlPath = tempDir.resolve("xcore.toml"); + write(tomlPath, """ + version = 1 + + [server] + name = "toml-server" + """); + + Path jsonPath = tempDir.resolve("xcconfig.json"); + write(jsonPath, """ + { + "server": "json-server" + } + """); + + ConfigFactory factory = new ConfigFactory(); + + // Act + Config config = factory.config(prettyGson); + + // Assert + assertThat(config.server).isEqualTo("toml-server"); + } + + @Test + @DisplayName("config falls back to legacy xcconfig.json when xcore.toml is absent") + void config_legacyJsonFallbackWorksWhenTomlAbsent() throws IOException { + // Arrange + Path jsonPath = tempDir.resolve("xcconfig.json"); + write(jsonPath, """ { - \"server\": \"event\", - \"disabled_commands\": null, - \"disabled_features\": null, - \"translation\": null + "server": "legacy-server", + "disabled_commands": null, + "disabled_features": null, + "translation": null } """); + + ConfigTomlLoader.setBackupTimestampSupplier(() -> LocalDateTime.of(2026, 5, 24, 18, 0, 1)); + ConfigFactory factory = new ConfigFactory(); // Act Config config = factory.config(prettyGson); // Assert - assertThat(config.server).isEqualTo("event"); + assertThat(config.server).isEqualTo("legacy-server"); assertThat(config.disabledCommands).isNotNull().isEmpty(); assertThat(config.disabledFeatures).isNotNull().isEmpty(); assertThat(config.translation).isNotNull(); assertThat(config.translation.pipeline).containsExactly("google"); + assertThat(tempDir.resolve("xcconfig.json")).doesNotExist(); + assertThat(tempDir.resolve("xcore.toml")).exists(); + assertThat(tempDir.resolve("xcconfig.json.bak-20260524-180001")).exists(); } @Test - @DisplayName("global config creates missing secrets file in configured directory before failing validation") - void globalConfig_createsMissingSecretsFileInConfiguredDirectoryBeforeFailingValidation() throws IOException { + @DisplayName("global config creates missing secrets.toml from defaults and fails required-field validation") + void globalConfig_createsMissingSecretsTomlFromDefaultsAndFailsValidation() throws IOException { // Arrange Path customGlobalDir = Files.createDirectories(tempDir.resolve("global-" + UUID.randomUUID())); Config config = new Config(); @@ -94,15 +131,83 @@ void globalConfig_createsMissingSecretsFileInConfiguredDirectoryBeforeFailingVal // Act + Assert assertThatThrownBy(() -> factory.globalConfig(config, prettyGson)) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Missing required config in secrets.json") - .hasMessageContaining("mongo_connection_string") - .hasMessageContaining("database_name"); + .hasMessageContaining("Missing required config in secrets.toml") + .hasMessageContaining("database.mongo_connection_string") + .hasMessageContaining("database.name"); - Path secretsPath = customGlobalDir.resolve("secrets.json"); + Path secretsPath = customGlobalDir.resolve("secrets.toml"); assertThat(secretsPath).exists(); assertThat(read(secretsPath)) - .contains("\"mongo_connection_string\": null") - .contains("\"database_name\": null"); + .contains("mongo_connection_string = \"\"") + .contains("name = \"\""); + } + + @Test + @DisplayName("global config loads secrets.toml when both TOML and legacy JSON exist") + void globalConfig_tomlTakesPrecedenceOverLegacyJson() throws IOException { + // Arrange + Path customGlobalDir = Files.createDirectories(tempDir.resolve("global-" + UUID.randomUUID())); + + Path tomlPath = customGlobalDir.resolve("secrets.toml"); + write(tomlPath, """ + version = 1 + + [database] + mongo_connection_string = "mongodb://toml:27017" + name = "toml-db" + """); + + Path jsonPath = customGlobalDir.resolve("secrets.json"); + write(jsonPath, """ + { + "mongo_connection_string": "mongodb://json:27017", + "database_name": "json-db" + } + """); + + Config config = new Config(); + config.globalConfigDirectory = customGlobalDir.toString(); + ConfigFactory factory = new ConfigFactory(); + + // Act + GlobalConfig globalConfig = factory.globalConfig(config, prettyGson); + + // Assert + assertThat(globalConfig.mongoConnectionString).isEqualTo("mongodb://toml:27017"); + assertThat(globalConfig.databaseName).isEqualTo("toml-db"); + } + + @Test + @DisplayName("global config falls back to legacy secrets.json when secrets.toml is absent") + void globalConfig_legacyJsonFallbackWorksWhenTomlAbsent() throws IOException { + // Arrange + Path customGlobalDir = Files.createDirectories(tempDir.resolve("global-" + UUID.randomUUID())); + + Path jsonPath = customGlobalDir.resolve("secrets.json"); + write(jsonPath, """ + { + "mongo_connection_string": "mongodb://legacy:27017", + "database_name": "legacy-db", + "translation_providers": null + } + """); + + ConfigTomlLoader.setBackupTimestampSupplier(() -> LocalDateTime.of(2026, 5, 24, 18, 0, 2)); + + Config config = new Config(); + config.globalConfigDirectory = customGlobalDir.toString(); + ConfigFactory factory = new ConfigFactory(); + + // Act + GlobalConfig globalConfig = factory.globalConfig(config, prettyGson); + + // Assert + assertThat(globalConfig.mongoConnectionString).isEqualTo("mongodb://legacy:27017"); + assertThat(globalConfig.databaseName).isEqualTo("legacy-db"); + assertThat(globalConfig.translationProviders).containsOnlyKeys("google"); + assertThat(customGlobalDir.resolve("secrets.json")).doesNotExist(); + assertThat(customGlobalDir.resolve("secrets.toml")).exists(); + assertThat(customGlobalDir.resolve("secrets.json.bak-20260524-180002")).exists(); } @Test @@ -110,13 +215,15 @@ void globalConfig_createsMissingSecretsFileInConfiguredDirectoryBeforeFailingVal void globalConfig_readsValidSecretsFromConfiguredDirectoryAndNormalizesProviders() throws IOException { // Arrange Path customGlobalDir = Files.createDirectories(tempDir.resolve("global-" + UUID.randomUUID())); - Path secretsPath = customGlobalDir.resolve("secrets.json"); + Path secretsPath = customGlobalDir.resolve("secrets.toml"); write(secretsPath, """ - { - \"mongo_connection_string\": \"mongodb://localhost:27017\", - \"database_name\": \"xcore\", - \"translation_providers\": null - } + version = 1 + + [database] + mongo_connection_string = "mongodb://localhost:27017" + name = "xcore" + + [translation] """); Config config = new Config(); diff --git a/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java b/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java new file mode 100644 index 00000000..a52e1141 --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java @@ -0,0 +1,213 @@ +package org.xcore.plugin.config; + +import arc.files.Fi; +import com.google.gson.Gson; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConfigTomlLoaderTest { + + @TempDir + Path tempDir; + + private final Gson gson = new SerializationFactory().prettyGson(); + + @AfterEach + void resetBackupTimestampSupplier() { + ConfigTomlLoader.resetBackupTimestampSupplier(); + } + + @Test + @DisplayName("loadXcoreConfig returns TOML source when xcore.toml exists") + void loadXcoreConfig_returnsTomlSource_whenTomlExists() throws IOException { + Path tomlPath = tempDir.resolve("xcore.toml"); + Files.writeString(tomlPath, """ + version = 1 + + [server] + name = "test-server" + player_limit = 42 + """); + + Fi dataDir = new Fi(tempDir.toFile()); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); + assertThat(result.file.name()).isEqualTo("xcore.toml"); + assertThat(result.config.server).isEqualTo("test-server"); + assertThat(result.config.playerLimit).isEqualTo(42); + } + + @Test + @DisplayName("loadXcoreConfig returns LEGACY_JSON source when only xcconfig.json exists") + void loadXcoreConfig_returnsLegacyJsonSource_whenOnlyJsonExists() throws IOException { + Path jsonPath = tempDir.resolve("xcconfig.json"); + Files.writeString(jsonPath, """ + { + "server": "legacy-server", + "player_limit": 99 + } + """); + + ConfigTomlLoader.setBackupTimestampSupplier(() -> LocalDateTime.of(2026, 5, 24, 16, 30, 45)); + + Fi dataDir = new Fi(tempDir.toFile()); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.MIGRATED); + assertThat(result.file.name()).isEqualTo("xcore.toml"); + assertThat(result.backupFile).isNotNull(); + assertThat(result.backupFile.name()).isEqualTo("xcconfig.json.bak-20260524-163045"); + assertThat(tempDir.resolve("xcconfig.json")).doesNotExist(); + assertThat(tempDir.resolve("xcore.toml")).exists(); + assertThat(tempDir.resolve("xcconfig.json.bak-20260524-163045")).exists(); + assertThat(result.config.server).isEqualTo("legacy-server"); + assertThat(result.config.playerLimit).isEqualTo(99); + } + + @Test + @DisplayName("loadXcoreConfig returns DEFAULT_TEMPLATE source and creates file when neither exists") + void loadXcoreConfig_returnsDefaultTemplateSource_whenNeitherExists() { + Fi dataDir = new Fi(tempDir.toFile()); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.DEFAULT_TEMPLATE); + assertThat(result.file.name()).isEqualTo("xcore.toml"); + assertThat(result.file.exists()).isTrue(); + assertThat(result.config.server).isEqualTo("server"); + assertThat(result.config.playerLimit).isEqualTo(30); + } + + @Test + @DisplayName("loadXcoreConfig prefers TOML over legacy JSON when both exist") + void loadXcoreConfig_prefersTomlOverLegacyJson() throws IOException { + Path tomlPath = tempDir.resolve("xcore.toml"); + Files.writeString(tomlPath, """ + version = 1 + + [server] + name = "toml-wins" + """); + + Path jsonPath = tempDir.resolve("xcconfig.json"); + Files.writeString(jsonPath, """ + { + "server": "json-loses" + } + """); + + Fi dataDir = new Fi(tempDir.toFile()); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); + assertThat(result.config.server).isEqualTo("toml-wins"); + } + + @Test + @DisplayName("loadGlobalConfig returns TOML source when secrets.toml exists") + void loadGlobalConfig_returnsTomlSource_whenTomlExists() throws IOException { + Path tomlPath = tempDir.resolve("secrets.toml"); + Files.writeString(tomlPath, """ + version = 1 + + [database] + mongo_connection_string = "mongodb://toml:27017" + name = "toml-db" + """); + + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadGlobalConfig(tempDir.toString(), gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); + assertThat(result.file.name()).isEqualTo("secrets.toml"); + assertThat(result.config.mongoConnectionString).isEqualTo("mongodb://toml:27017"); + assertThat(result.config.databaseName).isEqualTo("toml-db"); + } + + @Test + @DisplayName("loadGlobalConfig returns LEGACY_JSON source when only secrets.json exists") + void loadGlobalConfig_returnsLegacyJsonSource_whenOnlyJsonExists() throws IOException { + Path jsonPath = tempDir.resolve("secrets.json"); + Files.writeString(jsonPath, """ + { + "mongo_connection_string": "mongodb://json:27017", + "database_name": "json-db" + } + """); + + ConfigTomlLoader.setBackupTimestampSupplier(() -> LocalDateTime.of(2026, 5, 24, 16, 31, 46)); + + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadGlobalConfig(tempDir.toString(), gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.MIGRATED); + assertThat(result.file.name()).isEqualTo("secrets.toml"); + assertThat(result.backupFile).isNotNull(); + assertThat(result.backupFile.name()).isEqualTo("secrets.json.bak-20260524-163146"); + assertThat(tempDir.resolve("secrets.json")).doesNotExist(); + assertThat(tempDir.resolve("secrets.toml")).exists(); + assertThat(tempDir.resolve("secrets.json.bak-20260524-163146")).exists(); + assertThat(result.config.mongoConnectionString).isEqualTo("mongodb://json:27017"); + assertThat(result.config.databaseName).isEqualTo("json-db"); + } + + @Test + @DisplayName("loadGlobalConfig returns DEFAULT_TEMPLATE source and creates file when neither exists") + void loadGlobalConfig_returnsDefaultTemplateSource_whenNeitherExists() { + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadGlobalConfig(tempDir.toString(), gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.DEFAULT_TEMPLATE); + assertThat(result.file.name()).isEqualTo("secrets.toml"); + assertThat(result.file.exists()).isTrue(); + assertThat(result.config.mongoConnectionString).isNull(); + assertThat(result.config.databaseName).isNull(); + } + + @Test + @DisplayName("loadGlobalConfig prefers TOML over legacy JSON when both exist") + void loadGlobalConfig_prefersTomlOverLegacyJson() throws IOException { + Path tomlPath = tempDir.resolve("secrets.toml"); + Files.writeString(tomlPath, """ + version = 1 + + [database] + mongo_connection_string = "mongodb://toml:27017" + name = "toml-db" + """); + + Path jsonPath = tempDir.resolve("secrets.json"); + Files.writeString(jsonPath, """ + { + "mongo_connection_string": "mongodb://json:27017", + "database_name": "json-db" + } + """); + + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadGlobalConfig(tempDir.toString(), gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); + assertThat(result.config.mongoConnectionString).isEqualTo("mongodb://toml:27017"); + assertThat(result.config.databaseName).isEqualTo("toml-db"); + } + + @Test + @DisplayName("loadXcoreConfig backup helper creates deterministic sibling backup name") + void backupLegacyFile_createsDeterministicSiblingBackupName() throws IOException { + Path jsonPath = tempDir.resolve("xcconfig.json"); + Files.writeString(jsonPath, "{}"); + ConfigTomlLoader.setBackupTimestampSupplier(() -> LocalDateTime.of(2026, 5, 24, 17, 1, 2)); + + Fi backupFile = ConfigTomlLoader.backupLegacyFile(new Fi(jsonPath.toFile())); + + assertThat(jsonPath).doesNotExist(); + assertThat(backupFile.name()).isEqualTo("xcconfig.json.bak-20260524-170102"); + assertThat(tempDir.resolve("xcconfig.json.bak-20260524-170102")).exists(); + } +} diff --git a/src/test/java/org/xcore/plugin/config/ConfigTomlMapperTest.java b/src/test/java/org/xcore/plugin/config/ConfigTomlMapperTest.java new file mode 100644 index 00000000..629e8297 --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ConfigTomlMapperTest.java @@ -0,0 +1,593 @@ +package org.xcore.plugin.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ConfigTomlMapperTest { + + @Test + @DisplayName("toConfig throws IllegalArgumentException when toml is null") + void toConfig_throws_whenTomlIsNull() { + assertThatThrownBy(() -> ConfigTomlMapper.toConfig(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toml must not be null"); + } + + @Test + @DisplayName("toGlobalConfig throws IllegalArgumentException when toml is null") + void toGlobalConfig_throws_whenTomlIsNull() { + assertThatThrownBy(() -> ConfigTomlMapper.toGlobalConfig(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toml must not be null"); + } + + @Test + @DisplayName("toTomlXcoreConfig throws IllegalArgumentException when config is null") + void toTomlXcoreConfig_throws_whenConfigIsNull() { + assertThatThrownBy(() -> ConfigTomlMapper.toTomlXcoreConfig(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("config must not be null"); + } + + @Test + @DisplayName("toTomlSecretsConfig throws IllegalArgumentException when global is null") + void toTomlSecretsConfig_throws_whenGlobalIsNull() { + assertThatThrownBy(() -> ConfigTomlMapper.toTomlSecretsConfig(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("global must not be null"); + } + + @Test + @DisplayName("toConfig maps default DTO to legacy Config with correct defaults") + void toConfig_mapsDefaultDtoToLegacyConfig() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + + Config config = ConfigTomlMapper.toConfig(toml); + + assertThat(config.server).isEqualTo("server"); + assertThat(config.publicHostOverride).isNull(); + assertThat(config.playerLimit).isEqualTo(30); + assertThat(config.consoleEnabled).isTrue(); + assertThat(config.gameStartedTimer).isTrue(); + + assertThat(config.globalConfigDirectory).isNull(); + + assertThat(config.discordChannelId).isEqualTo(0L); + + assertThat(config.redisUrl).isEqualTo("redis://127.0.0.1:6379"); + assertThat(config.redisGroupPrefix).isEqualTo("xcore:cg"); + assertThat(config.redisConsumerName).isEqualTo("xcore-node"); + assertThat(config.redisReclaimEnabled).isTrue(); + assertThat(config.redisReclaimMinIdleMs).isEqualTo(15000L); + assertThat(config.redisReclaimBatch).isEqualTo(50); + assertThat(config.redisDlqEnabled).isTrue(); + assertThat(config.redisMaxDeliveryAttempts).isEqualTo(3); + assertThat(config.redisDlqPrefix).isEqualTo("xcore:dlq"); + + assertThat(config.disabledCommands).isNotNull().isEmpty(); + assertThat(config.disabledFeatures).isNotNull().isEmpty(); + + assertThat(config.isEventHubMap).isFalse(); + assertThat(config.eventHubMapID).isEqualTo(""); + + assertThat(config.translation.enabled).isTrue(); + assertThat(config.translation.pipeline).containsExactly("google"); + assertThat(config.translation.preserveOriginalMessageOnFailure).isTrue(); + assertThat(config.translation.cache.enabled).isTrue(); + assertThat(config.translation.cache.ttlSeconds).isEqualTo(1800); + assertThat(config.translation.cache.maxTextLength).isEqualTo(500); + assertThat(config.translation.metrics.enabled).isTrue(); + assertThat(config.translation.metrics.minuteBucketsEnabled).isTrue(); + assertThat(config.translation.metrics.minuteBucketTtlSeconds).isEqualTo(21600); + assertThat(config.translation.llm.preserveFormattingTokens).isTrue(); + assertThat(config.translation.llm.structuredOutputRequired).isTrue(); + assertThat(config.translation.llm.maxInputChars).isEqualTo(500); + assertThat(config.translation.llm.maxOutputChars).isEqualTo(1200); + assertThat(config.translation.llm.stripControlCharacters).isTrue(); + + assertThat(config.ipReputation.enabled).isFalse(); + assertThat(config.ipReputation.blockProxy).isTrue(); + assertThat(config.ipReputation.blockVpn).isTrue(); + assertThat(config.ipReputation.blockTor).isTrue(); + assertThat(config.ipReputation.blockHosting).isFalse(); + assertThat(config.ipReputation.cacheTtlSeconds).isEqualTo(3600); + } + + @Test + @DisplayName("toConfig maps populated DTO to legacy Config with correct values") + void toConfig_mapsPopulatedDtoToLegacyConfig() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.server.name = "mini-pvp"; + toml.server.publicHostOverride = "192.168.1.1"; + toml.server.playerLimit = 50; + toml.server.consoleEnabled = false; + toml.server.gameStartedTimer = false; + + toml.paths.globalConfigDirectory = "/opt/xcore/global"; + + toml.discord.channelId = 123456789L; + + toml.transport.redis.url = "redis://redis.example.com:6379"; + toml.transport.redis.groupPrefix = "xcore:prod"; + toml.transport.redis.consumerName = "xcore-prod-1"; + toml.transport.redis.reclaim.enabled = false; + toml.transport.redis.reclaim.minIdleMs = 30000L; + toml.transport.redis.reclaim.batch = 100; + toml.transport.redis.dlq.enabled = false; + toml.transport.redis.dlq.maxDeliveryAttempts = 5; + toml.transport.redis.dlq.prefix = "xcore:dlq:prod"; + + toml.runtime.disabledCommands = Set.of("rtv", "maps"); + toml.runtime.disabledFeatures = Set.of("chat"); + + toml.eventHub.enabled = true; + toml.eventHub.mapId = "event-hub-01"; + + toml.translation.enabled = false; + toml.translation.pipeline = List.of("google", "llm"); + toml.translation.preserveOriginalMessageOnFailure = false; + toml.translation.cache.enabled = false; + toml.translation.cache.ttlSeconds = 3600; + toml.translation.cache.maxTextLength = 1000; + toml.translation.metrics.enabled = false; + toml.translation.metrics.minuteBucketsEnabled = false; + toml.translation.metrics.minuteBucketTtlSeconds = 43200; + toml.translation.llm.preserveFormattingTokens = false; + toml.translation.llm.structuredOutputRequired = false; + toml.translation.llm.maxInputChars = 1000; + toml.translation.llm.maxOutputChars = 2000; + toml.translation.llm.stripControlCharacters = false; + + toml.ipReputation.enabled = true; + toml.ipReputation.blockProxy = false; + toml.ipReputation.blockVpn = false; + toml.ipReputation.blockTor = false; + toml.ipReputation.blockHosting = true; + toml.ipReputation.cacheTtlSeconds = 7200; + + Config config = ConfigTomlMapper.toConfig(toml); + + assertThat(config.server).isEqualTo("mini-pvp"); + assertThat(config.publicHostOverride).isEqualTo("192.168.1.1"); + assertThat(config.playerLimit).isEqualTo(50); + assertThat(config.consoleEnabled).isFalse(); + assertThat(config.gameStartedTimer).isFalse(); + + assertThat(config.globalConfigDirectory).isEqualTo("/opt/xcore/global"); + + assertThat(config.discordChannelId).isEqualTo(123456789L); + + assertThat(config.redisUrl).isEqualTo("redis://redis.example.com:6379"); + assertThat(config.redisGroupPrefix).isEqualTo("xcore:prod"); + assertThat(config.redisConsumerName).isEqualTo("xcore-prod-1"); + assertThat(config.redisReclaimEnabled).isFalse(); + assertThat(config.redisReclaimMinIdleMs).isEqualTo(30000L); + assertThat(config.redisReclaimBatch).isEqualTo(100); + assertThat(config.redisDlqEnabled).isFalse(); + assertThat(config.redisMaxDeliveryAttempts).isEqualTo(5); + assertThat(config.redisDlqPrefix).isEqualTo("xcore:dlq:prod"); + + assertThat(config.disabledCommands).containsExactlyInAnyOrder("rtv", "maps"); + assertThat(config.disabledFeatures).containsExactly("chat"); + + assertThat(config.isEventHubMap).isTrue(); + assertThat(config.eventHubMapID).isEqualTo("event-hub-01"); + + assertThat(config.translation.enabled).isFalse(); + assertThat(config.translation.pipeline).containsExactly("google", "llm"); + assertThat(config.translation.preserveOriginalMessageOnFailure).isFalse(); + assertThat(config.translation.cache.enabled).isFalse(); + assertThat(config.translation.cache.ttlSeconds).isEqualTo(3600); + assertThat(config.translation.cache.maxTextLength).isEqualTo(1000); + assertThat(config.translation.metrics.enabled).isFalse(); + assertThat(config.translation.metrics.minuteBucketsEnabled).isFalse(); + assertThat(config.translation.metrics.minuteBucketTtlSeconds).isEqualTo(43200); + assertThat(config.translation.llm.preserveFormattingTokens).isFalse(); + assertThat(config.translation.llm.structuredOutputRequired).isFalse(); + assertThat(config.translation.llm.maxInputChars).isEqualTo(1000); + assertThat(config.translation.llm.maxOutputChars).isEqualTo(2000); + assertThat(config.translation.llm.stripControlCharacters).isFalse(); + + assertThat(config.ipReputation.enabled).isTrue(); + assertThat(config.ipReputation.blockProxy).isFalse(); + assertThat(config.ipReputation.blockVpn).isFalse(); + assertThat(config.ipReputation.blockTor).isFalse(); + assertThat(config.ipReputation.blockHosting).isTrue(); + assertThat(config.ipReputation.cacheTtlSeconds).isEqualTo(7200); + } + + @Test + @DisplayName("toGlobalConfig maps default DTO to legacy GlobalConfig with correct defaults") + void toGlobalConfig_mapsDefaultDtoToLegacyGlobalConfig() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + + GlobalConfig global = ConfigTomlMapper.toGlobalConfig(toml); + + assertThat(global.mongoConnectionString).isNull(); // blankToNull + assertThat(global.databaseName).isNull(); // blankToNull + assertThat(global.isDataBaseReadOnly).isFalse(); + assertThat(global.isDataBaseMigration).isFalse(); + + assertThat(global.discordUrl).isEqualTo("https://discord.gg/RUMCCa9QAC"); + assertThat(global.githubUrl).isEqualTo("https://github.com/XCore-mindustry/"); + assertThat(global.donatelloUrl).isEqualTo("https://donatello.to/xcore"); + assertThat(global.weblateUrl).isEqualTo("https://xcore.eradication.fun/"); + assertThat(global.discordRedVSBlueUrl).isEqualTo("https://discord.gg/UdnuFetcNt"); + + assertThat(global.minPlayTimeForVotekick).isEqualTo(60); + assertThat(global.voteKickBanDurationMinutes).isEqualTo(30); + assertThat(global.voteDurationSeconds).isEqualTo(60.0f); + assertThat(global.minPlayTimeForGlobalChat).isEqualTo(240); + assertThat(global.mapSwitchDelaySeconds).isEqualTo(10); + + assertThat(global.eventsPerPage).isEqualTo(10); + assertThat(global.mapsPerPage).isEqualTo(10); + assertThat(global.commandsPerPage).isEqualTo(6); + assertThat(global.privateMessagesPerPage).isEqualTo(10); + + assertThat(global.maxHistory).isEqualTo(16); + assertThat(global.privateMessageMaxLength).isEqualTo(300); + assertThat(global.privateMessageCooldownSeconds).isEqualTo(10); + assertThat(global.privateMessageUnreadLimit).isEqualTo(30); + assertThat(global.privateMessageBlockedLimit).isEqualTo(100); + + assertThat(global.translationProviders).containsOnlyKeys("google"); + GlobalConfig.TranslationProviderConfig google = global.translationProviders.get("google"); + assertThat(google.type).isEqualTo("google"); + assertThat(google.enabled).isTrue(); + assertThat(google.apiKey).isNull(); // blankToNull + assertThat(google.baseUrl).isEqualTo("https://api.openai.com/v1"); + assertThat(google.model).isEqualTo("gpt-5.4"); + assertThat(google.apiMode).isNull(); // blankToNull + assertThat(google.organization).isNull(); // blankToNull + assertThat(google.project).isNull(); // blankToNull + assertThat(google.timeoutSeconds).isEqualTo(15); + assertThat(google.maxRetries).isEqualTo(1); + assertThat(google.temperature).isEqualTo(0.0); + assertThat(google.supportedLanguages).isNotNull().isEmpty(); + + assertThat(global.ipReputationProvider.baseUrl).isEqualTo("http://ip-api.com/json"); + assertThat(global.ipReputationProvider.timeoutSeconds).isEqualTo(10); + assertThat(global.ipReputationProvider.maxRetries).isEqualTo(2); + assertThat(global.ipReputationProvider.rateLimitPerMinute).isEqualTo(45); + } + + @Test + @DisplayName("toGlobalConfig maps populated DTO to legacy GlobalConfig with correct values") + void toGlobalConfig_mapsPopulatedDtoToLegacyGlobalConfig() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + toml.database.mongoConnectionString = "mongodb://localhost:27017"; + toml.database.name = "xcore"; + toml.database.readOnly = true; + toml.database.migrationEnabled = true; + + toml.externalLinks.discordUrl = "https://discord.gg/test"; + toml.externalLinks.githubUrl = "https://github.com/test/"; + toml.externalLinks.donatelloUrl = "https://donatello.to/test"; + toml.externalLinks.weblateUrl = "https://test.example.com/"; + toml.externalLinks.discordRedVSBlueUrl = "https://discord.gg/test2"; + + toml.moderation.votekick.minPlayTimeMinutes = 120; + toml.moderation.votekick.banDurationMinutes = 60; + toml.moderation.votekick.voteDurationSeconds = 120.0f; + + toml.chat.global.minPlayTimeMinutes = 300; + + toml.maps.voting.switchDelaySeconds = 20; + + toml.pagination.eventsPerPage = 20; + toml.pagination.mapsPerPage = 15; + toml.pagination.commandsPerPage = 10; + toml.pagination.privateMessagesPerPage = 20; + + toml.messages.history.maxHistory = 32; + toml.messages.privateMessages.maxLength = 500; + toml.messages.privateMessages.cooldownSeconds = 20; + toml.messages.privateMessages.unreadLimit = 50; + toml.messages.privateMessages.blockedLimit = 200; + + TomlSecretsConfig.TranslationSection.ProviderConfig customProvider = new TomlSecretsConfig.TranslationSection.ProviderConfig(); + customProvider.type = "llm"; + customProvider.enabled = false; + customProvider.apiKey = "secret-key"; + customProvider.baseUrl = "https://api.custom.com/v1"; + customProvider.model = "custom-model"; + customProvider.apiMode = "chat"; + customProvider.organization = "xcore-org"; + customProvider.project = "xcore-project"; + customProvider.timeoutSeconds = 30; + customProvider.maxRetries = 3; + customProvider.temperature = 0.5; + customProvider.supportedLanguages = Set.of("en", "ru"); + toml.translation.providers = new LinkedHashMap<>(Map.of("custom", customProvider)); + + toml.ipReputation.provider.baseUrl = "https://ip-api.example.com/json"; + toml.ipReputation.provider.timeoutSeconds = 20; + toml.ipReputation.provider.maxRetries = 5; + toml.ipReputation.provider.rateLimitPerMinute = 60; + + GlobalConfig global = ConfigTomlMapper.toGlobalConfig(toml); + + assertThat(global.mongoConnectionString).isEqualTo("mongodb://localhost:27017"); + assertThat(global.databaseName).isEqualTo("xcore"); + assertThat(global.isDataBaseReadOnly).isTrue(); + assertThat(global.isDataBaseMigration).isTrue(); + + assertThat(global.discordUrl).isEqualTo("https://discord.gg/test"); + assertThat(global.githubUrl).isEqualTo("https://github.com/test/"); + assertThat(global.donatelloUrl).isEqualTo("https://donatello.to/test"); + assertThat(global.weblateUrl).isEqualTo("https://test.example.com/"); + assertThat(global.discordRedVSBlueUrl).isEqualTo("https://discord.gg/test2"); + + assertThat(global.minPlayTimeForVotekick).isEqualTo(120); + assertThat(global.voteKickBanDurationMinutes).isEqualTo(60); + assertThat(global.voteDurationSeconds).isEqualTo(120.0f); + assertThat(global.minPlayTimeForGlobalChat).isEqualTo(300); + assertThat(global.mapSwitchDelaySeconds).isEqualTo(20); + + assertThat(global.eventsPerPage).isEqualTo(20); + assertThat(global.mapsPerPage).isEqualTo(15); + assertThat(global.commandsPerPage).isEqualTo(10); + assertThat(global.privateMessagesPerPage).isEqualTo(20); + + assertThat(global.maxHistory).isEqualTo(32); + assertThat(global.privateMessageMaxLength).isEqualTo(500); + assertThat(global.privateMessageCooldownSeconds).isEqualTo(20); + assertThat(global.privateMessageUnreadLimit).isEqualTo(50); + assertThat(global.privateMessageBlockedLimit).isEqualTo(200); + + assertThat(global.translationProviders).containsOnlyKeys("custom"); + GlobalConfig.TranslationProviderConfig custom = global.translationProviders.get("custom"); + assertThat(custom.type).isEqualTo("llm"); + assertThat(custom.enabled).isFalse(); + assertThat(custom.apiKey).isEqualTo("secret-key"); + assertThat(custom.baseUrl).isEqualTo("https://api.custom.com/v1"); + assertThat(custom.model).isEqualTo("custom-model"); + assertThat(custom.apiMode).isEqualTo("chat"); + assertThat(custom.organization).isEqualTo("xcore-org"); + assertThat(custom.project).isEqualTo("xcore-project"); + assertThat(custom.timeoutSeconds).isEqualTo(30); + assertThat(custom.maxRetries).isEqualTo(3); + assertThat(custom.temperature).isEqualTo(0.5); + assertThat(custom.supportedLanguages).containsExactlyInAnyOrder("en", "ru"); + + assertThat(global.ipReputationProvider.baseUrl).isEqualTo("https://ip-api.example.com/json"); + assertThat(global.ipReputationProvider.timeoutSeconds).isEqualTo(20); + assertThat(global.ipReputationProvider.maxRetries).isEqualTo(5); + assertThat(global.ipReputationProvider.rateLimitPerMinute).isEqualTo(60); + } + + @Test + @DisplayName("toGlobalConfig converts blank optional strings to null") + void toGlobalConfig_convertsBlankOptionalStringsToNull() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + toml.database.mongoConnectionString = " "; + toml.database.name = "\t"; + + TomlSecretsConfig.TranslationSection.ProviderConfig provider = toml.translation.providers.get("google"); + provider.apiKey = ""; + provider.apiMode = " "; + provider.organization = ""; + provider.project = " "; + + GlobalConfig global = ConfigTomlMapper.toGlobalConfig(toml); + + assertThat(global.mongoConnectionString).isNull(); + assertThat(global.databaseName).isNull(); + + GlobalConfig.TranslationProviderConfig mappedProvider = global.translationProviders.get("google"); + assertThat(mappedProvider.apiKey).isNull(); + assertThat(mappedProvider.apiMode).isNull(); + assertThat(mappedProvider.organization).isNull(); + assertThat(mappedProvider.project).isNull(); + } + + @Test + @DisplayName("toConfig handles null nested sections by normalizing first") + void toConfig_handlesNullNestedSections_byNormalizingFirst() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.server = null; + toml.translation = null; + toml.ipReputation = null; + + Config config = ConfigTomlMapper.toConfig(toml); + + assertThat(config.server).isEqualTo("server"); + assertThat(config.translation).isNotNull(); + assertThat(config.translation.pipeline).containsExactly("google"); + assertThat(config.ipReputation).isNotNull(); + assertThat(config.ipReputation.enabled).isFalse(); + } + + @Test + @DisplayName("toGlobalConfig handles null nested sections by normalizing first") + void toGlobalConfig_handlesNullNestedSections_byNormalizingFirst() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + toml.moderation = null; + toml.translation = null; + toml.ipReputation = null; + + GlobalConfig global = ConfigTomlMapper.toGlobalConfig(toml); + + assertThat(global.minPlayTimeForVotekick).isEqualTo(60); + assertThat(global.translationProviders).containsOnlyKeys("google"); + assertThat(global.ipReputationProvider).isNotNull(); + assertThat(global.ipReputationProvider.baseUrl).isEqualTo("http://ip-api.com/json"); + } + + @Test + @DisplayName("toTomlXcoreConfig maps populated legacy Config to TOML DTO") + void toTomlXcoreConfig_mapsPopulatedLegacyConfig() { + Config config = new Config(); + config.server = "event"; + config.publicHostOverride = null; + config.playerLimit = 64; + config.consoleEnabled = false; + config.gameStartedTimer = false; + config.globalConfigDirectory = "/srv/xcore/global"; + config.discordChannelId = 55L; + config.redisUrl = "redis://prod:6379"; + config.redisGroupPrefix = "xcore:prod"; + config.redisConsumerName = "consumer-a"; + config.redisReclaimEnabled = false; + config.redisReclaimMinIdleMs = 32000L; + config.redisReclaimBatch = 77; + config.redisDlqEnabled = false; + config.redisMaxDeliveryAttempts = 7; + config.redisDlqPrefix = "xcore:dlq:prod"; + config.disabledCommands = Set.of("maps", "rtv"); + config.disabledFeatures = Set.of("translation"); + config.isEventHubMap = true; + config.eventHubMapID = "hub-map"; + config.translation.enabled = false; + config.translation.pipeline = List.of("google", "llm"); + config.translation.preserveOriginalMessageOnFailure = false; + config.translation.cache.enabled = false; + config.translation.cache.ttlSeconds = 999; + config.translation.cache.maxTextLength = 1234; + config.translation.metrics.enabled = false; + config.translation.metrics.minuteBucketsEnabled = false; + config.translation.metrics.minuteBucketTtlSeconds = 5678; + config.translation.llm.preserveFormattingTokens = false; + config.translation.llm.structuredOutputRequired = false; + config.translation.llm.maxInputChars = 111; + config.translation.llm.maxOutputChars = 222; + config.translation.llm.stripControlCharacters = false; + config.ipReputation.enabled = true; + config.ipReputation.blockProxy = false; + config.ipReputation.blockVpn = false; + config.ipReputation.blockTor = false; + config.ipReputation.blockHosting = true; + config.ipReputation.cacheTtlSeconds = 9999; + + TomlXcoreConfig toml = ConfigTomlMapper.toTomlXcoreConfig(config); + + assertThat(toml.server.name).isEqualTo("event"); + assertThat(toml.server.publicHostOverride).isNull(); + assertThat(toml.server.playerLimit).isEqualTo(64); + assertThat(toml.server.consoleEnabled).isFalse(); + assertThat(toml.server.gameStartedTimer).isFalse(); + assertThat(toml.paths.globalConfigDirectory).isEqualTo("/srv/xcore/global"); + assertThat(toml.discord.channelId).isEqualTo(55L); + assertThat(toml.transport.redis.url).isEqualTo("redis://prod:6379"); + assertThat(toml.transport.redis.groupPrefix).isEqualTo("xcore:prod"); + assertThat(toml.transport.redis.consumerName).isEqualTo("consumer-a"); + assertThat(toml.transport.redis.reclaim.enabled).isFalse(); + assertThat(toml.transport.redis.reclaim.minIdleMs).isEqualTo(32000L); + assertThat(toml.transport.redis.reclaim.batch).isEqualTo(77); + assertThat(toml.transport.redis.dlq.enabled).isFalse(); + assertThat(toml.transport.redis.dlq.maxDeliveryAttempts).isEqualTo(7); + assertThat(toml.transport.redis.dlq.prefix).isEqualTo("xcore:dlq:prod"); + assertThat(toml.runtime.disabledCommands).containsExactlyInAnyOrder("maps", "rtv"); + assertThat(toml.runtime.disabledFeatures).containsExactly("translation"); + assertThat(toml.eventHub.enabled).isTrue(); + assertThat(toml.eventHub.mapId).isEqualTo("hub-map"); + assertThat(toml.translation.pipeline).containsExactly("google", "llm"); + assertThat(toml.translation.llm.maxOutputChars).isEqualTo(222); + assertThat(toml.ipReputation.enabled).isTrue(); + assertThat(toml.ipReputation.blockHosting).isTrue(); + assertThat(toml.ipReputation.cacheTtlSeconds).isEqualTo(9999); + } + + @Test + @DisplayName("toTomlSecretsConfig maps populated legacy GlobalConfig to TOML DTO") + void toTomlSecretsConfig_mapsPopulatedLegacyGlobalConfig() { + GlobalConfig global = new GlobalConfig(); + global.mongoConnectionString = null; + global.databaseName = null; + global.isDataBaseReadOnly = true; + global.isDataBaseMigration = true; + global.discordUrl = "https://discord.gg/custom"; + global.githubUrl = "https://github.com/custom"; + global.donatelloUrl = "https://donate/custom"; + global.weblateUrl = "https://weblate/custom"; + global.discordRedVSBlueUrl = "https://discord.gg/rvb"; + global.minPlayTimeForVotekick = 1; + global.voteKickBanDurationMinutes = 2; + global.voteDurationSeconds = 3.5f; + global.minPlayTimeForGlobalChat = 4; + global.mapSwitchDelaySeconds = 5; + global.eventsPerPage = 6; + global.mapsPerPage = 7; + global.commandsPerPage = 8; + global.privateMessagesPerPage = 9; + global.maxHistory = 10; + global.privateMessageMaxLength = 11; + global.privateMessageCooldownSeconds = 12; + global.privateMessageUnreadLimit = 13; + global.privateMessageBlockedLimit = 14; + + GlobalConfig.TranslationProviderConfig provider = new GlobalConfig.TranslationProviderConfig(); + provider.type = "llm"; + provider.enabled = false; + provider.apiKey = null; + provider.baseUrl = "https://api.provider/v1"; + provider.model = "gpt-x"; + provider.apiMode = null; + provider.organization = "org"; + provider.project = null; + provider.timeoutSeconds = 15; + provider.maxRetries = 16; + provider.temperature = 0.7; + provider.supportedLanguages = Set.of("en", "ru"); + global.translationProviders = new LinkedHashMap<>(Map.of("ai", provider)); + + global.ipReputationProvider.baseUrl = "https://ip.example/json"; + global.ipReputationProvider.timeoutSeconds = 20; + global.ipReputationProvider.maxRetries = 21; + global.ipReputationProvider.rateLimitPerMinute = 22; + + TomlSecretsConfig toml = ConfigTomlMapper.toTomlSecretsConfig(global); + + assertThat(toml.database.mongoConnectionString).isEmpty(); + assertThat(toml.database.name).isEmpty(); + assertThat(toml.database.readOnly).isTrue(); + assertThat(toml.database.migrationEnabled).isTrue(); + assertThat(toml.externalLinks.discordUrl).isEqualTo("https://discord.gg/custom"); + assertThat(toml.externalLinks.githubUrl).isEqualTo("https://github.com/custom"); + assertThat(toml.externalLinks.donatelloUrl).isEqualTo("https://donate/custom"); + assertThat(toml.externalLinks.weblateUrl).isEqualTo("https://weblate/custom"); + assertThat(toml.externalLinks.discordRedVSBlueUrl).isEqualTo("https://discord.gg/rvb"); + assertThat(toml.moderation.votekick.minPlayTimeMinutes).isEqualTo(1); + assertThat(toml.moderation.votekick.banDurationMinutes).isEqualTo(2); + assertThat(toml.moderation.votekick.voteDurationSeconds).isEqualTo(3.5f); + assertThat(toml.chat.global.minPlayTimeMinutes).isEqualTo(4); + assertThat(toml.maps.voting.switchDelaySeconds).isEqualTo(5); + assertThat(toml.pagination.eventsPerPage).isEqualTo(6); + assertThat(toml.pagination.mapsPerPage).isEqualTo(7); + assertThat(toml.pagination.commandsPerPage).isEqualTo(8); + assertThat(toml.pagination.privateMessagesPerPage).isEqualTo(9); + assertThat(toml.messages.history.maxHistory).isEqualTo(10); + assertThat(toml.messages.privateMessages.maxLength).isEqualTo(11); + assertThat(toml.messages.privateMessages.cooldownSeconds).isEqualTo(12); + assertThat(toml.messages.privateMessages.unreadLimit).isEqualTo(13); + assertThat(toml.messages.privateMessages.blockedLimit).isEqualTo(14); + assertThat(toml.translation.providers).containsOnlyKeys("ai"); + + TomlSecretsConfig.TranslationSection.ProviderConfig mappedProvider = toml.translation.providers.get("ai"); + assertThat(mappedProvider.type).isEqualTo("llm"); + assertThat(mappedProvider.enabled).isFalse(); + assertThat(mappedProvider.apiKey).isEmpty(); + assertThat(mappedProvider.baseUrl).isEqualTo("https://api.provider/v1"); + assertThat(mappedProvider.model).isEqualTo("gpt-x"); + assertThat(mappedProvider.apiMode).isEmpty(); + assertThat(mappedProvider.organization).isEqualTo("org"); + assertThat(mappedProvider.project).isEmpty(); + assertThat(mappedProvider.timeoutSeconds).isEqualTo(15); + assertThat(mappedProvider.maxRetries).isEqualTo(16); + assertThat(mappedProvider.temperature).isEqualTo(0.7); + assertThat(mappedProvider.supportedLanguages).containsExactlyInAnyOrder("en", "ru"); + assertThat(toml.ipReputation.provider.baseUrl).isEqualTo("https://ip.example/json"); + assertThat(toml.ipReputation.provider.timeoutSeconds).isEqualTo(20); + assertThat(toml.ipReputation.provider.maxRetries).isEqualTo(21); + assertThat(toml.ipReputation.provider.rateLimitPerMinute).isEqualTo(22); + } +} diff --git a/src/test/java/org/xcore/plugin/config/GlobalConfigTest.java b/src/test/java/org/xcore/plugin/config/GlobalConfigTest.java index b72471f9..e49fd948 100644 --- a/src/test/java/org/xcore/plugin/config/GlobalConfigTest.java +++ b/src/test/java/org/xcore/plugin/config/GlobalConfigTest.java @@ -20,14 +20,14 @@ class GlobalConfigTest { void postInit_throwsClearErrorWhenRequiredFieldsAreMissing() { // Arrange GlobalConfig globalConfig = new GlobalConfig(); - Fi globalConfigFile = new Fi(tempDir.resolve("secrets.json").toFile()); + Fi globalConfigFile = new Fi(tempDir.resolve("secrets.toml").toFile()); // Act + Assert assertThatThrownBy(() -> globalConfig.postInit(globalConfigFile)) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("secrets.json") - .hasMessageContaining("mongo_connection_string") - .hasMessageContaining("database_name"); + .hasMessageContaining("secrets.toml") + .hasMessageContaining("database.mongo_connection_string") + .hasMessageContaining("database.name"); } @Test @@ -38,7 +38,7 @@ void postInit_acceptsValidConfigurationAndNormalizesTranslationProviders() { globalConfig.mongoConnectionString = "mongodb://localhost:27017"; globalConfig.databaseName = "xcore"; globalConfig.translationProviders = null; - Fi globalConfigFile = new Fi(tempDir.resolve("secrets.json").toFile()); + Fi globalConfigFile = new Fi(tempDir.resolve("secrets.toml").toFile()); // Act + Assert assertThatCode(() -> globalConfig.postInit(globalConfigFile)) diff --git a/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java b/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java new file mode 100644 index 00000000..b239d68d --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java @@ -0,0 +1,103 @@ +package org.xcore.plugin.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ServerLocalConfigPathEditorTest { + + private final ServerLocalConfigPathEditor editor = new ServerLocalConfigPathEditor(new SerializationFactory().prettyGson()); + + @Test + @DisplayName("update supports legacy alias paths") + void update_supportsLegacyAliasPaths() { + Config updated = editor.update(new Config(), "playerLimit", "64"); + + assertThat(updated).isNotNull(); + assertThat(updated.playerLimit).isEqualTo(64); + } + + @Test + @DisplayName("update supports canonical TOML dotted paths") + void update_supportsCanonicalTomlDottedPaths() { + Config updated = editor.update(new Config(), "server.player_limit", "48"); + + assertThat(updated).isNotNull(); + assertThat(updated.playerLimit).isEqualTo(48); + } + + @Test + @DisplayName("update supports nested transport redis dotted paths") + void update_supportsNestedTransportRedisDottedPaths() { + Config updated = editor.update(new Config(), "transport.redis.url", "redis://example:6379"); + + assertThat(updated).isNotNull(); + assertThat(updated.redisUrl).isEqualTo("redis://example:6379"); + } + + @Test + @DisplayName("update parses comma separated translation pipeline and drops blanks") + void update_parsesCommaSeparatedTranslationPipelineAndDropsBlanks() { + Config updated = editor.update(new Config(), "translation.pipeline", " google , , openai , deepl "); + + assertThat(updated).isNotNull(); + assertThat(updated.translation.pipeline).containsExactly("google", "openai", "deepl"); + } + + @Test + @DisplayName("update parses JSON array translation pipeline") + void update_parsesJsonArrayTranslationPipeline() { + Config updated = editor.update(new Config(), "translation.pipeline", "[\"google\", \"openai\"]"); + + assertThat(updated).isNotNull(); + assertThat(updated.translation.pipeline).containsExactly("google", "openai"); + } + + @Test + @DisplayName("update returns null for unsupported path") + void update_returnsNullForUnsupportedPath() { + Config updated = editor.update(new Config(), "missing.path", "value"); + + assertThat(updated).isNull(); + } + + @Test + @DisplayName("update throws friendly exception for invalid boolean value") + void update_throwsFriendlyExceptionForInvalidBooleanValue() { + assertThatThrownBy(() -> editor.update(new Config(), "consoleEnabled", "maybe")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid value 'maybe' for 'console_enabled' (expected true or false)."); + } + + @Test + @DisplayName("update throws friendly exception for invalid integer value") + void update_throwsFriendlyExceptionForInvalidIntegerValue() { + assertThatThrownBy(() -> editor.update(new Config(), "playerLimit", "not-a-number")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid value 'not-a-number' for 'player_limit' (expected integer number).") + .cause() + .isInstanceOf(NumberFormatException.class); + } + + @Test + @DisplayName("update throws friendly exception for invalid long value") + void update_throwsFriendlyExceptionForInvalidLongValue() { + assertThatThrownBy(() -> editor.update(new Config(), "discordChannelId", "not-a-long")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid value 'not-a-long' for 'channel_id' (expected integer number).") + .cause() + .isInstanceOf(NumberFormatException.class); + } + + @Test + @DisplayName("update throws friendly exception for malformed translation pipeline JSON") + void update_throwsFriendlyExceptionForMalformedTranslationPipelineJson() { + assertThatThrownBy(() -> editor.update(new Config(), "translation.pipeline", "[\"google\",")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid value '[\"google\",' for 'translation.pipeline' (expected a comma-separated list or JSON string array).") + .cause() + .isNotNull(); + } +} diff --git a/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java new file mode 100644 index 00000000..0c91eadf --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java @@ -0,0 +1,105 @@ +package org.xcore.plugin.config; + +import arc.files.Fi; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ServerLocalConfigTomlStoreTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("write persists Config to xcore.toml and round-trips correctly") + void write_persistsConfig_andRoundTrips() throws IOException { + Path tomlPath = tempDir.resolve("xcore.toml"); + Fi tomlFile = new Fi(tomlPath.toFile()); + ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); + + Config config = new Config(); + config.server = "test-server"; + config.playerLimit = 42; + config.consoleEnabled = false; + config.publicHostOverride = "192.168.1.1"; + config.disabledCommands = Set.of("rtv", "maps"); + config.disabledFeatures = Set.of("chat"); + + store.write(config); + + assertThat(tomlPath).exists(); + String written = Files.readString(tomlPath); + assertThat(written).isNotBlank(); + + // Round-trip: load back via ConfigTomlLoader + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( + new Fi(tempDir.toFile()), + new SerializationFactory().prettyGson() + ); + assertThat(result.config.server).isEqualTo("test-server"); + assertThat(result.config.playerLimit).isEqualTo(42); + assertThat(result.config.consoleEnabled).isFalse(); + assertThat(result.config.publicHostOverride).isEqualTo("192.168.1.1"); + assertThat(result.config.disabledCommands).containsExactlyInAnyOrder("rtv", "maps"); + assertThat(result.config.disabledFeatures).containsExactly("chat"); + } + + @Test + @DisplayName("write normalizes config before persisting") + void write_normalizesBeforePersisting() { + Path tomlPath = tempDir.resolve("xcore.toml"); + Fi tomlFile = new Fi(tomlPath.toFile()); + ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); + + Config config = new Config(); + config.server = null; // will normalize to "server" via TomlXcoreConfig + config.disabledCommands = null; + + store.write(config); + + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( + new Fi(tempDir.toFile()), + new SerializationFactory().prettyGson() + ); + assertThat(result.config.server).isEqualTo("server"); + assertThat(result.config.disabledCommands).isNotNull().isEmpty(); + } + + @Test + @DisplayName("constructor throws when tomlFile is null") + void constructor_throws_whenTomlFileIsNull() { + assertThatThrownBy(() -> new ServerLocalConfigTomlStore(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("tomlFile must not be null"); + } + + @Test + @DisplayName("write throws when config is null") + void write_throws_whenConfigIsNull() { + Path tomlPath = tempDir.resolve("xcore.toml"); + Fi tomlFile = new Fi(tomlPath.toFile()); + ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); + + assertThatThrownBy(() -> store.write(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("config must not be null"); + } + + @Test + @DisplayName("file returns the configured TOML file handle") + void file_returnsConfiguredTomlFile() { + Path tomlPath = tempDir.resolve("xcore.toml"); + Fi tomlFile = new Fi(tomlPath.toFile()); + ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); + + assertThat(store.file()).isEqualTo(tomlFile); + } +} diff --git a/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java b/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java new file mode 100644 index 00000000..faf8022c --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java @@ -0,0 +1,176 @@ +package org.xcore.plugin.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TomlSecretsConfigTest { + + @Test + @DisplayName("fresh instance has defaults matching legacy GlobalConfig") + void freshInstance_hasDefaultsMatchingLegacyGlobalConfig() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + + assertThat(toml.version).isEqualTo(1); + + assertThat(toml.database.mongoConnectionString).isEqualTo(""); + assertThat(toml.database.name).isEqualTo(""); + assertThat(toml.database.readOnly).isFalse(); + assertThat(toml.database.migrationEnabled).isFalse(); + + assertThat(toml.externalLinks.discordUrl).isEqualTo("https://discord.gg/RUMCCa9QAC"); + assertThat(toml.externalLinks.githubUrl).isEqualTo("https://github.com/XCore-mindustry/"); + assertThat(toml.externalLinks.donatelloUrl).isEqualTo("https://donatello.to/xcore"); + assertThat(toml.externalLinks.weblateUrl).isEqualTo("https://xcore.eradication.fun/"); + assertThat(toml.externalLinks.discordRedVSBlueUrl).isEqualTo("https://discord.gg/UdnuFetcNt"); + + assertThat(toml.moderation.votekick.minPlayTimeMinutes).isEqualTo(60); + assertThat(toml.moderation.votekick.banDurationMinutes).isEqualTo(30); + assertThat(toml.moderation.votekick.voteDurationSeconds).isEqualTo(60.0f); + + assertThat(toml.chat.global.minPlayTimeMinutes).isEqualTo(240); + + assertThat(toml.maps.voting.switchDelaySeconds).isEqualTo(10); + + assertThat(toml.pagination.eventsPerPage).isEqualTo(10); + assertThat(toml.pagination.mapsPerPage).isEqualTo(10); + assertThat(toml.pagination.commandsPerPage).isEqualTo(6); + assertThat(toml.pagination.privateMessagesPerPage).isEqualTo(10); + + assertThat(toml.messages.history.maxHistory).isEqualTo(16); + assertThat(toml.messages.privateMessages.maxLength).isEqualTo(300); + assertThat(toml.messages.privateMessages.cooldownSeconds).isEqualTo(10); + assertThat(toml.messages.privateMessages.unreadLimit).isEqualTo(30); + assertThat(toml.messages.privateMessages.blockedLimit).isEqualTo(100); + + assertThat(toml.translation.providers).containsOnlyKeys("google"); + TomlSecretsConfig.TranslationSection.ProviderConfig google = toml.translation.providers.get("google"); + assertThat(google.type).isEqualTo("google"); + assertThat(google.enabled).isTrue(); + assertThat(google.apiKey).isEqualTo(""); + assertThat(google.baseUrl).isEqualTo("https://api.openai.com/v1"); + assertThat(google.model).isEqualTo("gpt-5.4"); + assertThat(google.apiMode).isEqualTo(""); + assertThat(google.organization).isEqualTo(""); + assertThat(google.project).isEqualTo(""); + assertThat(google.timeoutSeconds).isEqualTo(15); + assertThat(google.maxRetries).isEqualTo(1); + assertThat(google.temperature).isEqualTo(0.0); + assertThat(google.supportedLanguages).isNotNull().isEmpty(); + + assertThat(toml.ipReputation.provider.baseUrl).isEqualTo("http://ip-api.com/json"); + assertThat(toml.ipReputation.provider.timeoutSeconds).isEqualTo(10); + assertThat(toml.ipReputation.provider.maxRetries).isEqualTo(2); + assertThat(toml.ipReputation.provider.rateLimitPerMinute).isEqualTo(45); + } + + @Test + @DisplayName("normalize repairs null nested sections") + void normalize_repairsNullNestedSections() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + toml.database = null; + toml.externalLinks = null; + toml.moderation = null; + toml.chat = null; + toml.maps = null; + toml.pagination = null; + toml.messages = null; + toml.translation = null; + toml.ipReputation = null; + + toml.normalize(); + + assertThat(toml.database).isNotNull(); + assertThat(toml.externalLinks).isNotNull(); + assertThat(toml.moderation).isNotNull(); + assertThat(toml.chat).isNotNull(); + assertThat(toml.maps).isNotNull(); + assertThat(toml.pagination).isNotNull(); + assertThat(toml.messages).isNotNull(); + assertThat(toml.translation).isNotNull(); + assertThat(toml.ipReputation).isNotNull(); + + assertThat(toml.moderation.votekick).isNotNull(); + assertThat(toml.chat.global).isNotNull(); + assertThat(toml.maps.voting).isNotNull(); + assertThat(toml.messages.history).isNotNull(); + assertThat(toml.messages.privateMessages).isNotNull(); + assertThat(toml.translation.providers).containsOnlyKeys("google"); + assertThat(toml.ipReputation.provider).isNotNull(); + } + + @Test + @DisplayName("normalize creates default google provider when empty") + void normalize_createsDefaultGoogleProvider_whenEmpty() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + toml.translation.providers.clear(); + + toml.normalize(); + + assertThat(toml.translation.providers).containsOnlyKeys("google"); + assertThat(toml.translation.providers.get("google").type).isEqualTo("google"); + } + + @Test + @DisplayName("normalize repairs invalid provider fields") + void normalize_repairsInvalidProviderFields() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + TomlSecretsConfig.TranslationSection.ProviderConfig provider = new TomlSecretsConfig.TranslationSection.ProviderConfig(); + provider.type = ""; + provider.baseUrl = ""; + provider.model = ""; + provider.timeoutSeconds = 0; + provider.maxRetries = -1; + provider.supportedLanguages = null; + toml.translation.providers = new LinkedHashMap<>(Map.of("llm", provider)); + + toml.normalize(); + + TomlSecretsConfig.TranslationSection.ProviderConfig normalized = toml.translation.providers.get("llm"); + assertThat(normalized.type).isEqualTo("google"); + assertThat(normalized.baseUrl).isEqualTo("https://api.openai.com/v1"); + assertThat(normalized.model).isEqualTo("gpt-5.4"); + assertThat(normalized.timeoutSeconds).isEqualTo(15); + assertThat(normalized.maxRetries).isEqualTo(1); + assertThat(normalized.supportedLanguages).isNotNull().isEmpty(); + } + + @Test + @DisplayName("normalize preserves blank optional strings for mapper blankToNull") + void normalize_preservesBlankOptionalStrings_forMapperBlankToNull() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + TomlSecretsConfig.TranslationSection.ProviderConfig provider = toml.translation.providers.get("google"); + provider.apiKey = ""; + provider.apiMode = " "; + provider.organization = "\t"; + provider.project = ""; + + toml.normalize(); + + assertThat(provider.apiKey).isEqualTo(""); + assertThat(provider.apiMode).isEqualTo(" "); + assertThat(provider.organization).isEqualTo("\t"); + assertThat(provider.project).isEqualTo(""); + } + + @Test + @DisplayName("normalize repairs invalid ip reputation provider fields") + void normalize_repairsInvalidIpReputationProviderFields() { + TomlSecretsConfig toml = new TomlSecretsConfig(); + toml.ipReputation.provider.baseUrl = ""; + toml.ipReputation.provider.timeoutSeconds = 0; + toml.ipReputation.provider.maxRetries = -1; + toml.ipReputation.provider.rateLimitPerMinute = -5; + + toml.normalize(); + + assertThat(toml.ipReputation.provider.baseUrl).isEqualTo("http://ip-api.com/json"); + assertThat(toml.ipReputation.provider.timeoutSeconds).isEqualTo(10); + assertThat(toml.ipReputation.provider.maxRetries).isEqualTo(2); + assertThat(toml.ipReputation.provider.rateLimitPerMinute).isEqualTo(45); + } +} diff --git a/src/test/java/org/xcore/plugin/config/TomlXcoreConfigTest.java b/src/test/java/org/xcore/plugin/config/TomlXcoreConfigTest.java new file mode 100644 index 00000000..aaa56a9c --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/TomlXcoreConfigTest.java @@ -0,0 +1,174 @@ +package org.xcore.plugin.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TomlXcoreConfigTest { + + @Test + @DisplayName("fresh instance has defaults matching legacy Config") + void freshInstance_hasDefaultsMatchingLegacyConfig() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + + assertThat(toml.version).isEqualTo(1); + assertThat(toml.server.name).isEqualTo("server"); + assertThat(toml.server.publicHostOverride).isEqualTo(""); + assertThat(toml.server.playerLimit).isEqualTo(30); + assertThat(toml.server.consoleEnabled).isTrue(); + assertThat(toml.server.gameStartedTimer).isTrue(); + + assertThat(toml.paths.globalConfigDirectory).isEqualTo(""); + + assertThat(toml.discord.channelId).isEqualTo(0L); + + assertThat(toml.transport.redis.url).isEqualTo("redis://127.0.0.1:6379"); + assertThat(toml.transport.redis.groupPrefix).isEqualTo("xcore:cg"); + assertThat(toml.transport.redis.consumerName).isEqualTo("xcore-node"); + assertThat(toml.transport.redis.reclaim.enabled).isTrue(); + assertThat(toml.transport.redis.reclaim.minIdleMs).isEqualTo(15000L); + assertThat(toml.transport.redis.reclaim.batch).isEqualTo(50); + assertThat(toml.transport.redis.dlq.enabled).isTrue(); + assertThat(toml.transport.redis.dlq.maxDeliveryAttempts).isEqualTo(3); + assertThat(toml.transport.redis.dlq.prefix).isEqualTo("xcore:dlq"); + + assertThat(toml.runtime.disabledCommands).isNotNull().isEmpty(); + assertThat(toml.runtime.disabledFeatures).isNotNull().isEmpty(); + + assertThat(toml.eventHub.enabled).isFalse(); + assertThat(toml.eventHub.mapId).isEqualTo(""); + + assertThat(toml.translation.enabled).isTrue(); + assertThat(toml.translation.pipeline).containsExactly("google"); + assertThat(toml.translation.preserveOriginalMessageOnFailure).isTrue(); + assertThat(toml.translation.cache.enabled).isTrue(); + assertThat(toml.translation.cache.ttlSeconds).isEqualTo(1800); + assertThat(toml.translation.cache.maxTextLength).isEqualTo(500); + assertThat(toml.translation.metrics.enabled).isTrue(); + assertThat(toml.translation.metrics.minuteBucketsEnabled).isTrue(); + assertThat(toml.translation.metrics.minuteBucketTtlSeconds).isEqualTo(21600); + assertThat(toml.translation.llm.preserveFormattingTokens).isTrue(); + assertThat(toml.translation.llm.structuredOutputRequired).isTrue(); + assertThat(toml.translation.llm.maxInputChars).isEqualTo(500); + assertThat(toml.translation.llm.maxOutputChars).isEqualTo(1200); + assertThat(toml.translation.llm.stripControlCharacters).isTrue(); + + assertThat(toml.ipReputation.enabled).isFalse(); + assertThat(toml.ipReputation.blockProxy).isTrue(); + assertThat(toml.ipReputation.blockVpn).isTrue(); + assertThat(toml.ipReputation.blockTor).isTrue(); + assertThat(toml.ipReputation.blockHosting).isFalse(); + assertThat(toml.ipReputation.cacheTtlSeconds).isEqualTo(3600); + } + + @Test + @DisplayName("normalize repairs null nested sections") + void normalize_repairsNullNestedSections() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.server = null; + toml.paths = null; + toml.discord = null; + toml.transport = null; + toml.runtime = null; + toml.eventHub = null; + toml.translation = null; + toml.ipReputation = null; + + toml.normalize(); + + assertThat(toml.server).isNotNull(); + assertThat(toml.paths).isNotNull(); + assertThat(toml.discord).isNotNull(); + assertThat(toml.transport).isNotNull(); + assertThat(toml.runtime).isNotNull(); + assertThat(toml.eventHub).isNotNull(); + assertThat(toml.translation).isNotNull(); + assertThat(toml.ipReputation).isNotNull(); + + assertThat(toml.server.name).isEqualTo("server"); + assertThat(toml.transport.redis.url).isEqualTo("redis://127.0.0.1:6379"); + assertThat(toml.translation.pipeline).containsExactly("google"); + } + + @Test + @DisplayName("normalize converts blank optional strings to null") + void normalize_convertsBlankOptionalStringsToNull() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.server.publicHostOverride = " "; + toml.paths.globalConfigDirectory = "\t"; + + toml.normalize(); + + assertThat(toml.server.publicHostOverride).isNull(); + assertThat(toml.paths.globalConfigDirectory).isNull(); + } + + @Test + @DisplayName("normalize repairs invalid numeric values") + void normalize_repairsInvalidNumericValues() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.transport.redis.reclaim.minIdleMs = -1; + toml.transport.redis.reclaim.batch = 0; + toml.transport.redis.dlq.maxDeliveryAttempts = -5; + toml.translation.cache.ttlSeconds = 0; + toml.translation.cache.maxTextLength = -1; + toml.translation.metrics.minuteBucketTtlSeconds = 0; + toml.translation.llm.maxInputChars = -10; + toml.translation.llm.maxOutputChars = 0; + toml.ipReputation.cacheTtlSeconds = -1; + + toml.normalize(); + + assertThat(toml.transport.redis.reclaim.minIdleMs).isEqualTo(15000L); + assertThat(toml.transport.redis.reclaim.batch).isEqualTo(50); + assertThat(toml.transport.redis.dlq.maxDeliveryAttempts).isEqualTo(3); + assertThat(toml.translation.cache.ttlSeconds).isEqualTo(1800); + assertThat(toml.translation.cache.maxTextLength).isEqualTo(500); + assertThat(toml.translation.metrics.minuteBucketTtlSeconds).isEqualTo(21600); + assertThat(toml.translation.llm.maxInputChars).isEqualTo(500); + assertThat(toml.translation.llm.maxOutputChars).isEqualTo(1200); + assertThat(toml.ipReputation.cacheTtlSeconds).isEqualTo(3600); + } + + @Test + @DisplayName("normalize restores default pipeline when empty") + void normalize_restoresDefaultPipeline_whenEmpty() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.translation.pipeline.clear(); + + toml.normalize(); + + assertThat(toml.translation.pipeline).containsExactly("google"); + } + + @Test + @DisplayName("normalize repairs null redis nested sections") + void normalize_repairsNullRedisNestedSections() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.transport.redis.reclaim = null; + toml.transport.redis.dlq = null; + + toml.normalize(); + + assertThat(toml.transport.redis.reclaim).isNotNull(); + assertThat(toml.transport.redis.reclaim.enabled).isTrue(); + assertThat(toml.transport.redis.dlq).isNotNull(); + assertThat(toml.transport.redis.dlq.prefix).isEqualTo("xcore:dlq"); + } + + @Test + @DisplayName("normalize repairs null translation nested sections") + void normalize_repairsNullTranslationNestedSections() { + TomlXcoreConfig toml = new TomlXcoreConfig(); + toml.translation.cache = null; + toml.translation.metrics = null; + toml.translation.llm = null; + + toml.normalize(); + + assertThat(toml.translation.cache).isNotNull(); + assertThat(toml.translation.metrics).isNotNull(); + assertThat(toml.translation.llm).isNotNull(); + } +} From 0f46f1a30fe169b6e12f5eae5c044fa8d8d421aa Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 18:43:56 +0000 Subject: [PATCH 2/6] fix: apply CodeRabbit auto-fixes Fixed 4 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit --- .../command/controller/server/DataController.java | 9 +++++++-- .../java/org/xcore/plugin/config/ConfigTomlLoader.java | 10 ++++++++-- .../org/xcore/plugin/config/TomlSecretsConfig.java | 10 ++-------- .../org/xcore/plugin/config/TomlSecretsConfigTest.java | 8 ++++---- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/xcore/plugin/command/controller/server/DataController.java b/src/main/java/org/xcore/plugin/command/controller/server/DataController.java index 4ebac28a..641ea2b6 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/DataController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/DataController.java @@ -95,9 +95,14 @@ public void xconfigEdit(XCoreSender sender, return; } + try { + tomlStore.write(updated); + } catch (Exception e) { + Log.err("Failed to persist config change for field '@': @", field, e.getMessage()); + return; + } config = updated; - tomlStore.write(config); - Log.info("Config field '@' updated to '@'.", field, value); + Log.info("Config field '@' updated.", field); } @Command("edit-data ") diff --git a/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java b/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java index dbbbdbd6..1cb7baac 100644 --- a/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java +++ b/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java @@ -219,7 +219,10 @@ private static Fi resolveGlobalDir(String globalConfigDirectory) { } private static LoadResult migrateLegacyXcoreConfig(Fi jsonFile, Fi tomlFile, Gson gson) { - Config legacyConfig = gson.fromJson(jsonFile.reader(), Config.class); + Config legacyConfig; + try (var reader = jsonFile.reader()) { + legacyConfig = gson.fromJson(reader, Config.class); + } if (legacyConfig == null) { legacyConfig = new Config(); } @@ -234,7 +237,10 @@ private static LoadResult migrateLegacyXcoreConfig(Fi jsonFile, Fi tomlF } private static LoadResult migrateLegacySecretsConfig(Fi jsonFile, Fi tomlFile, Gson gson) { - GlobalConfig legacyGlobal = gson.fromJson(jsonFile.reader(), GlobalConfig.class); + GlobalConfig legacyGlobal; + try (var reader = jsonFile.reader()) { + legacyGlobal = gson.fromJson(reader, GlobalConfig.class); + } if (legacyGlobal == null) { legacyGlobal = new GlobalConfig(); } diff --git a/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java b/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java index 97892144..9028a8b3 100644 --- a/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java +++ b/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java @@ -173,8 +173,8 @@ public static class ProviderConfig { public String type = "google"; public boolean enabled = true; public String apiKey = ""; - public String baseUrl = "https://api.openai.com/v1"; - public String model = "gpt-5.4"; + public String baseUrl = ""; + public String model = ""; public String apiMode = ""; public String organization = ""; public String project = ""; @@ -187,12 +187,6 @@ public void normalize() { if (type == null || type.isBlank()) { type = "google"; } - if (baseUrl == null || baseUrl.isBlank()) { - baseUrl = "https://api.openai.com/v1"; - } - if (model == null || model.isBlank()) { - model = "gpt-5.4"; - } if (apiMode != null && !apiMode.isBlank()) { apiMode = apiMode.trim().toLowerCase(); } diff --git a/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java b/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java index faf8022c..35999f79 100644 --- a/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java +++ b/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java @@ -52,8 +52,8 @@ void freshInstance_hasDefaultsMatchingLegacyGlobalConfig() { assertThat(google.type).isEqualTo("google"); assertThat(google.enabled).isTrue(); assertThat(google.apiKey).isEqualTo(""); - assertThat(google.baseUrl).isEqualTo("https://api.openai.com/v1"); - assertThat(google.model).isEqualTo("gpt-5.4"); + assertThat(google.baseUrl).isEqualTo(""); + assertThat(google.model).isEqualTo(""); assertThat(google.apiMode).isEqualTo(""); assertThat(google.organization).isEqualTo(""); assertThat(google.project).isEqualTo(""); @@ -132,8 +132,8 @@ void normalize_repairsInvalidProviderFields() { TomlSecretsConfig.TranslationSection.ProviderConfig normalized = toml.translation.providers.get("llm"); assertThat(normalized.type).isEqualTo("google"); - assertThat(normalized.baseUrl).isEqualTo("https://api.openai.com/v1"); - assertThat(normalized.model).isEqualTo("gpt-5.4"); + assertThat(normalized.baseUrl).isEqualTo(""); + assertThat(normalized.model).isEqualTo(""); assertThat(normalized.timeoutSeconds).isEqualTo(15); assertThat(normalized.maxRetries).isEqualTo(1); assertThat(normalized.supportedLanguages).isNotNull().isEmpty(); From ab4d7acde2f904a0e7b85ddc96cd6c5feea692e6 Mon Sep 17 00:00:00 2001 From: osp54 <76648940+osp54@users.noreply.github.com> Date: Thu, 28 May 2026 21:39:56 +0300 Subject: [PATCH 3/6] refactor(config): migrate all direct Config consumers to TomlXcoreConfig Complete the structured-config migration away from legacy org.xcore.plugin.config.Config toward org.xcore.plugin.config.TomlXcoreConfig across 28 production files and 21 test files. Migration slices completed: - ModerationService, command controllers, leaf cleanup (Console, MaintainController, HexedRanks) - TranslationProviderFactory, shared menu plumbing (11 menu files) - Isolated gamemode guards (LastStanding, MiniPvP) - Moderate-risk runtime consumers (GameLifecycleHandler, MiniHexedService, VoteKick) - Final consumers (MigrationService, SmartMapSelector) Preserved behavior: server identity publication, moderation protocol payloads, translation pipeline ordering, menu rendering, gamemode guards, DB lock ownership, map rotation, and all existing test assertions. No remaining direct Config imports in production or test code. --- .../server-local-config-refactor-spec.md | 328 ++++++++++++++++++ .../cloud/config/DisabledCommandPolicy.java | 12 +- .../plugin/command/CloudCommandRegistrar.java | 18 +- .../controller/client/AuthController.java | 11 +- .../controller/client/BadgeController.java | 10 +- .../controller/client/SocialController.java | 12 +- .../server/BadgeAdminController.java | 8 +- .../controller/server/DataController.java | 25 +- .../controller/server/MaintainController.java | 16 +- .../server/RuntimeToggleConfigService.java | 14 +- .../server/TranslationStatsController.java | 8 +- .../xcore/plugin/config/ConfigFactory.java | 11 +- .../xcore/plugin/config/ConfigTomlLoader.java | 31 +- .../config/ServerLocalConfigPathEditor.java | 14 +- .../config/ServerLocalConfigTomlRenderer.java | 9 +- .../config/ServerLocalConfigTomlStore.java | 27 +- .../plugin/config/TomlSecretsConfig.java | 10 +- .../database/migration/MigrationService.java | 12 +- .../xcore/plugin/event/TransportService.java | 18 +- .../event/handler/ConnectionHandler.java | 10 +- .../event/handler/GameLifecycleHandler.java | 10 +- .../plugin/event/handler/MapVoteHandler.java | 8 +- .../event/net/chat/ChatMessageHandler.java | 8 +- .../event/transport/ChatTransportHandler.java | 10 +- .../DiscordLinkTransportHandler.java | 6 +- .../event/transport/MapTransportHandler.java | 12 +- .../transport/ModerationTransportHandler.java | 10 +- .../plugin/gamemode/hexed/HexedRanks.java | 6 +- .../gamemode/hexed/MiniHexedService.java | 10 +- .../gamemode/laststanding/LastStanding.java | 8 +- .../xcore/plugin/gamemode/pvp/MiniPvP.java | 8 +- .../org/xcore/plugin/integration/Console.java | 8 +- .../TranslationProviderFactory.java | 6 +- .../xcore/plugin/map/SmartMapSelector.java | 14 +- .../ingress/checks/PlayerLimitCheck.java | 16 +- .../IpReputationOrchestrationService.java | 6 +- .../ipreputation/IpReputationPolicy.java | 6 +- .../plugin/service/DiscordLinkService.java | 20 +- .../xcore/plugin/service/GameDataService.java | 12 +- .../org/xcore/plugin/service/MapService.java | 20 +- .../plugin/service/PlayerDisplayService.java | 12 +- .../service/PlayerProfileSettingsService.java | 12 +- .../plugin/service/PrivateMessageService.java | 8 +- .../service/ServerDiscoveryService.java | 14 +- .../plugin/service/TopMenuCacheService.java | 8 +- .../xcore/plugin/service/TopMenuService.java | 18 +- .../service/TranslationCacheService.java | 8 +- .../service/TranslationMetricsService.java | 14 +- .../service/TranslationSafetyService.java | 6 +- .../plugin/service/TranslatorService.java | 6 +- .../moderation/DefaultAuditService.java | 8 +- .../service/moderation/ModerationService.java | 18 +- .../network/RedisConnectionManager.java | 10 +- .../network/RedisDiscordLinkCodeStore.java | 8 +- .../service/network/RedisEnvelopeFactory.java | 20 +- .../network/RedisIpReputationAllowlist.java | 10 +- .../network/RedisIpReputationCache.java | 8 +- .../service/network/RedisNetworkBackend.java | 38 +- .../network/RedisObserverStateStore.java | 8 +- .../service/network/RedisStreamSupport.java | 18 +- .../plugin/ui/menu/AuditHistoryMenu.java | 6 +- .../org/xcore/plugin/ui/menu/BanMenu.java | 6 +- .../org/xcore/plugin/ui/menu/DiscordMenu.java | 6 +- .../org/xcore/plugin/ui/menu/EventMenu.java | 5 +- .../org/xcore/plugin/ui/menu/HelpMenu.java | 5 +- .../xcore/plugin/ui/menu/InformationMenu.java | 12 +- .../org/xcore/plugin/ui/menu/MapFlows.java | 20 +- .../org/xcore/plugin/ui/menu/MapMenu.java | 8 +- .../java/org/xcore/plugin/ui/menu/Menu.java | 5 +- .../org/xcore/plugin/ui/menu/MessageMenu.java | 6 +- .../org/xcore/plugin/ui/menu/PlayerMenu.java | 6 +- .../org/xcore/plugin/ui/menu/TopMenu.java | 6 +- .../java/org/xcore/plugin/vote/VoteKick.java | 12 +- .../CloudCommandPipelineIntegrationTest.java | 6 +- .../config/DisabledCommandPolicyTest.java | 12 +- .../command/CloudCommandRegistrarTest.java | 121 +++++++ .../client/BadgeControllerTest.java | 98 ++++++ .../client/HexedControllerObserverTest.java | 4 +- .../client/SocialControllerTest.java | 87 +++++ .../server/BadgeAdminControllerTest.java | 84 +++++ .../controller/server/DataControllerTest.java | 78 +++-- .../server/MaintainControllerTest.java | 43 +-- .../RuntimeToggleConfigServiceTest.java | 43 +++ .../TranslationStatsControllerTest.java | 6 +- .../plugin/config/ConfigFactoryTest.java | 18 +- .../plugin/config/ConfigTomlLoaderTest.java | 22 +- .../ServerLocalConfigPathEditorTest.java | 48 ++- .../ServerLocalConfigTomlRendererTest.java | 35 ++ .../ServerLocalConfigTomlStoreTest.java | 51 ++- .../plugin/config/TomlSecretsConfigTest.java | 8 +- .../plugin/event/NetEventServiceTest.java | 16 +- .../plugin/event/TransportServiceTest.java | 12 +- .../event/handler/ConnectionHandlerTest.java | 6 +- .../transport/ChatTransportHandlerTest.java | 18 +- .../DiscordLinkTransportHandlerTest.java | 9 +- .../transport/MapTransportHandlerTest.java | 203 +++++++++++ .../ModerationTransportHandlerTest.java | 22 +- .../gamemode/ObserverFlowRegressionTest.java | 4 +- .../gamemode/pvp/MiniPvPRoundStateTest.java | 4 +- .../OpenAIResponseParserTest.java | 4 +- .../OpenAITranslationProviderTest.java | 4 +- .../TranslationProviderFactoryTest.java | 99 ++++++ .../ingress/checks/PlayerLimitCheckTest.java | 14 +- .../IpReputationOrchestrationServiceTest.java | 34 +- .../ipreputation/IpReputationPolicyTest.java | 18 +- .../service/DiscordLinkServiceTest.java | 38 +- .../plugin/service/GameDataServiceTest.java | 36 +- .../xcore/plugin/service/MapServiceTest.java | 12 +- .../service/MapServiceVoteNewWaveTest.java | 12 +- .../service/PlayerDisplayServiceTest.java | 8 +- .../PlayerProfileSettingsServiceTest.java | 34 +- .../service/PrivateMessageServiceTest.java | 20 +- .../service/ServerDiscoveryServiceTest.java | 205 +++++++++++ .../service/TopMenuCacheServiceTest.java | 8 +- .../plugin/service/TopMenuServiceTest.java | 8 +- .../service/TranslationCacheServiceTest.java | 165 +++++++++ .../TranslationMetricsServiceTest.java | 172 +++++++++ .../service/TranslationSafetyServiceTest.java | 13 +- .../plugin/service/TranslatorServiceTest.java | 29 +- .../moderation/DefaultAuditServiceTest.java | 6 +- .../ModerationServiceAvajeTest.java | 14 +- .../RedisIpReputationAllowlistTest.java | 8 +- .../network/RedisIpReputationCacheTest.java | 8 +- .../RedisNetworkBackendIntegrationTest.java | 76 ++-- .../service/network/RedisSupportTest.java | 10 +- .../plugin/ui/AuditHistoryMenuFlowTest.java | 3 +- .../java/org/xcore/plugin/ui/BanMenuTest.java | 3 +- .../org/xcore/plugin/ui/DiscordMenuTest.java | 5 +- .../org/xcore/plugin/ui/EventMenuTest.java | 5 +- .../org/xcore/plugin/ui/HelpMenuTest.java | 4 +- .../xcore/plugin/ui/InformationMenuTest.java | 14 +- .../org/xcore/plugin/ui/MessageMenuTest.java | 3 +- .../org/xcore/plugin/ui/menu/MapMenuTest.java | 16 +- .../org/xcore/plugin/ui/menu/MenuTest.java | 4 +- .../xcore/plugin/ui/menu/PlayerMenuTest.java | 7 +- .../org/xcore/plugin/ui/menu/TopMenuTest.java | 27 +- 136 files changed, 2552 insertions(+), 815 deletions(-) create mode 100644 docs/architecture/server-local-config-refactor-spec.md create mode 100644 src/test/java/org/xcore/plugin/command/CloudCommandRegistrarTest.java create mode 100644 src/test/java/org/xcore/plugin/command/controller/client/BadgeControllerTest.java create mode 100644 src/test/java/org/xcore/plugin/command/controller/client/SocialControllerTest.java create mode 100644 src/test/java/org/xcore/plugin/command/controller/server/BadgeAdminControllerTest.java create mode 100644 src/test/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigServiceTest.java create mode 100644 src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlRendererTest.java create mode 100644 src/test/java/org/xcore/plugin/event/transport/MapTransportHandlerTest.java create mode 100644 src/test/java/org/xcore/plugin/localization/TranslationProviderFactoryTest.java create mode 100644 src/test/java/org/xcore/plugin/service/ServerDiscoveryServiceTest.java create mode 100644 src/test/java/org/xcore/plugin/service/TranslationCacheServiceTest.java create mode 100644 src/test/java/org/xcore/plugin/service/TranslationMetricsServiceTest.java diff --git a/docs/architecture/server-local-config-refactor-spec.md b/docs/architecture/server-local-config-refactor-spec.md new file mode 100644 index 00000000..b92e16d7 --- /dev/null +++ b/docs/architecture/server-local-config-refactor-spec.md @@ -0,0 +1,328 @@ +# Server-Local Config Refactor Spec + +## 1. Motivation & Context + +The current TOML redesign solved the user-facing file-format problem, but it did +not complete the architectural refactor. + +Today, server-local configuration still has two competing shapes: + +- `TomlXcoreConfig` — the structured TOML shape used for persistence and file + semantics +- `Config` — the legacy runtime shape used by commands, services, and tests + +`ConfigTomlMapper` exists because those two shapes do not match. As a result, +the current design keeps routing almost every config workflow through an adapter +layer: + +- startup load: `TomlXcoreConfig -> Config` +- legacy JSON migration: `Config -> TomlXcoreConfig -> Config` +- persistence: `Config -> TomlXcoreConfig` +- `xconfig` edit: `Config -> TomlXcoreConfig -> edited tree -> Config` +- `xconfig` show: `Config -> TomlXcoreConfig -> TOML` + +This creates several problems: + +1. The mapper is the architectural center instead of a boundary detail. +2. `ConfigTomlLoader` owns too many responsibilities: file resolution, + migration, template creation, TOML I/O, and runtime-model creation. +3. `DataController`, `RuntimeToggleConfigService`, and related helpers mutate a + legacy runtime model that is no longer the real persistence shape. +4. The runtime model leaks historical field layout into new code and tests. +5. The current design makes future config evolution depend on adapter work + instead of a clear source of truth. + +The user has explicitly rejected the mapper-centric end state and wants a +proper refactor. + +## 2. Decision Summary + +Server-local configuration should move to a **single structured runtime model** +that matches the TOML boundary closely enough to be the source of truth for: + +- startup load +- runtime inspection +- runtime mutation +- persistence back to `xcore.toml` + +`ConfigTomlMapper` must stop being the design center. During migration, a +compatibility adapter may still exist temporarily for legacy consumers, but it +must become an edge concern rather than the primary flow for every config +operation. + +The first implementation slice will refactor **server-local config only**. It +will not refactor `GlobalConfig` / `TomlSecretsConfig` in the same slice. + +## 3. Goals + +- Establish a clear server-local config source of truth. +- Remove adapter round-trips from the `xconfig` read/edit/persist flow. +- Reduce the responsibilities currently concentrated in + `ConfigTomlLoader` and `ConfigTomlMapper`. +- Preserve TOML-first loading and legacy JSON migration behavior. +- Keep command UX stable while refactoring internals. + +## 4. Non-Goals + +- No hot reload. +- No simultaneous full refactor of `GlobalConfig`. +- No removal of legacy JSON migration in the first slice. +- No broad rewrite of every direct `Config` consumer in one step. +- No command registration redesign or unrelated controller cleanup. + +## 5. Current Architecture + +### 5.1 Current Source of Truth Problem + +The current codebase behaves as if server-local config has two owners: + +- `TomlXcoreConfig` owns persistence structure and TOML semantics. +- `Config` owns runtime mutation and most consumers. + +Because ownership is split, the mapper became a permanent bridge instead of a +temporary migration device. + +### 5.2 Current Coupling Surfaces + +The current server-local flow crosses these classes: + +- `ConfigFactory` +- `ConfigTomlLoader` +- `ConfigTomlMapper` +- `TomlXcoreConfig` +- `Config` +- `ServerLocalConfigTomlStore` +- `ServerLocalConfigTomlRenderer` +- `ServerLocalConfigPathEditor` +- `DataController` +- `RuntimeToggleConfigService` +- `MaintainController` + +The most problematic hotspots are: + +1. **`ConfigTomlLoader`** + - resolves files + - migrates legacy JSON + - writes TOML + - reads TOML + - creates runtime models + +2. **`ConfigTomlMapper`** + - used in both directions + - used in startup, migration, render, edit, and persistence + +3. **`ServerLocalConfigPathEditor`** + - edits by converting `Config -> TomlXcoreConfig -> JSON tree -> TomlXcoreConfig -> Config` + +4. **`DataController` / `RuntimeToggleConfigService`** + - mutate a legacy model, then depend on TOML mapping during persistence + +## 6. Target Architecture + +### 6.1 Target Boundary + +Server-local config should have one runtime owner, referred to in this spec as +`ServerLocalConfigModel`. + +This model should: + +- represent the structured server-local config shape +- carry runtime defaults and normalization rules +- be directly used by load/render/edit/persist flows +- be stable enough for operator-facing commands + +In the target state: + +- TOML file I/O reads/writes `ServerLocalConfigModel` directly, or through a + very thin serialization DTO if a separate DTO still proves necessary +- `ServerLocalConfigPathEditor` edits `ServerLocalConfigModel` directly +- `ServerLocalConfigTomlRenderer` renders `ServerLocalConfigModel` directly +- `ServerLocalConfigTomlStore` persists `ServerLocalConfigModel` directly +- `DataController` and `RuntimeToggleConfigService` operate on the same model + +### 6.2 Allowed Transitional Compatibility + +During rollout, legacy consumers that still depend on `Config` may use a +**temporary compatibility adapter**. That adapter must be constrained to the +consumer boundary and must not remain the center of every config workflow. + +Acceptable temporary pattern: + +`ServerLocalConfigModel -> legacy Config view` + +Not acceptable as end state: + +`Config <-> TomlXcoreConfig` in every load/edit/store/render path. + +### 6.3 Desired Dependency Direction + +Target direction for server-local config: + +```text +DataController / RuntimeToggleConfigService + -> ServerLocalConfigRuntime boundary + -> persistence/render/edit helpers + -> TOML file boundary +``` + +The controller should not be the architecture. The runtime config boundary must +own mutation semantics, while file I/O remains an adapter concern. + +## 7. Ownership & Responsibilities + +### 7.1 `ConfigFactory` +- remains the startup composition boundary +- wires the runtime config owner and related helpers +- should not assemble config behavior from multiple competing shapes forever + +### 7.2 Loader Boundary +- resolve `xcore.toml` +- handle legacy `xcconfig.json` migration +- deserialize into the structured runtime model +- keep migration/backup behavior explicit + +### 7.3 Runtime Config Boundary +- own normalization/default rules relevant to runtime behavior +- expose stable mutation and read surfaces for controllers/services +- prevent file-format details from leaking into unrelated consumers + +### 7.4 Renderer / Store / Path Editor +- act on the structured runtime model +- stop depending on a central `ConfigTomlMapper` +- remain focused helpers, not mini-loaders + +## 8. First Vertical Slice Scope + +The first slice should refactor the server-local config lifecycle end-to-end. + +### In Scope + +- `ConfigTomlLoader` +- `ConfigFactory` +- `TomlXcoreConfig` +- `Config` +- `ConfigTomlMapper` (reduction / edge-only usage) +- `ServerLocalConfigTomlStore` +- `ServerLocalConfigTomlRenderer` +- `ServerLocalConfigPathEditor` +- `DataController` +- `RuntimeToggleConfigService` +- `MaintainController` + +### Out of Scope + +- `GlobalConfig` +- `TomlSecretsConfig` +- secrets/global provider refactor +- cross-repo config consumers +- removal of legacy JSON migration support + +### Slice Success Criteria + +The first slice is successful when: + +1. `xconfig show` renders from the new runtime owner without adapter round-trip. +2. `xconfig edit` mutates the new runtime owner without adapter round-trip. +3. runtime toggles persist through the new runtime owner. +4. startup still loads TOML first and still migrates legacy JSON safely. +5. any temporary compatibility adapter is reduced to a narrow legacy-consumer edge. + +## 9. Persistence & File-Format Semantics + +The file-level behavior must stay stable during the refactor: + +- `xcore.toml` remains the primary server-local config file. +- `xcconfig.json` remains a legacy migration source only. +- migration still creates `.bak-YYYYMMDD-HHMMSS` backups. +- normalization/default semantics remain compatible with current TOML behavior. +- unknown TOML fields should remain rejected by strict parsing. + +This refactor changes runtime ownership, not the user-facing TOML contract. + +## 10. Proposed Rollout Plan + +### Phase 1 — Design Boundary +- introduce and document the structured runtime owner for server-local config +- define where compatibility adapters are still temporarily allowed + +### Phase 2 — Internal Server-Local Runtime Slice +- make store/render/path editor operate on the new runtime owner +- move `DataController` and `RuntimeToggleConfigService` to that owner +- keep startup and persistence behavior stable + +### Phase 3 — Legacy Consumer Reduction +- identify remaining direct `Config` consumers in server-local paths +- migrate them subsystem by subsystem +- reduce `ConfigTomlMapper` to a temporary compatibility edge + +### Phase 4 — Evaluate Global Config Strategy +- decide whether `GlobalConfig` gets the same structured-runtime treatment +- do not start this until the server-local slice proves the pattern + +## 11. Validation Strategy + +### Targeted Validation +- config loader tests +- path editor tests +- `DataControllerTest` +- `MaintainControllerTest` +- TOML store / render tests + +### Broad Validation +- `./gradlew test` +- `./gradlew test shadowJar` for packaging/startup-sensitive slices + +### Behavioral Invariants +- no regression in TOML-first startup behavior +- no regression in JSON migration/backups +- no regression in `xconfig` path support +- no regression in dedicated toggle-owned path guard behavior + +## 12. Risks & Mitigations + +### Risk: Widespread `Config` field usage +**Mitigation:** constrain the first slice to server-local operator flows and +runtime toggles before touching broader consumers. + +### Risk: Breaking startup ordering +**Mitigation:** keep `ConfigFactory` as the composition root and preserve the +current load ordering contract. + +### Risk: Replacing one adapter maze with another +**Mitigation:** make the new runtime owner the design center; do not create a +second permanent compatibility layer. + +### Risk: Behavior drift in normalization/defaults +**Mitigation:** keep current TOML contract stable and lock it with targeted +tests before and after the slice. + +## 13. Open Questions / Deferred Work + +1. Should the long-term public runtime type still be named `Config`, or should a + new explicit server-local config type own the boundary? +2. Should `TomlXcoreConfig` become the runtime model directly, or should a new + structured runtime model replace both `TomlXcoreConfig` and legacy `Config`? +3. How much compatibility is required for direct field access in existing + server-local consumers during rollout? +4. When should `GlobalConfig` receive the same treatment? +5. When can `ConfigTomlMapper` be deleted entirely instead of reduced to an + edge adapter? + +## 14. First Vertical Slice Planning Inputs + +The first implementation plan should reference at least these files: + +- `src/main/java/org/xcore/plugin/config/ConfigFactory.java` +- `src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java` +- `src/main/java/org/xcore/plugin/config/ConfigTomlMapper.java` +- `src/main/java/org/xcore/plugin/config/TomlXcoreConfig.java` +- `src/main/java/org/xcore/plugin/config/Config.java` +- `src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java` +- `src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java` +- `src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java` +- `src/main/java/org/xcore/plugin/command/controller/server/DataController.java` +- `src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java` +- `src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java` + +This list is intentionally server-local only. `GlobalConfig` and +`TomlSecretsConfig` remain a later decision. diff --git a/src/main/java/org/xcore/plugin/cloud/config/DisabledCommandPolicy.java b/src/main/java/org/xcore/plugin/cloud/config/DisabledCommandPolicy.java index afc42bcf..d7959feb 100644 --- a/src/main/java/org/xcore/plugin/cloud/config/DisabledCommandPolicy.java +++ b/src/main/java/org/xcore/plugin/cloud/config/DisabledCommandPolicy.java @@ -5,7 +5,7 @@ import org.incendo.cloud.Command; import org.incendo.cloud.component.CommandComponent; import org.xcore.plugin.cloud.XCoreSender; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.util.Locale; import java.util.stream.Collectors; @@ -13,10 +13,10 @@ @Singleton public class DisabledCommandPolicy { - private final Config config; + private final TomlXcoreConfig config; @Inject - public DisabledCommandPolicy(Config config) { + public DisabledCommandPolicy(TomlXcoreConfig config) { this.config = config; } @@ -66,7 +66,7 @@ public String disabledCommandKey(String input) { return null; } - for (String disabledCommand : config.disabledCommands) { + for (String disabledCommand : config.runtime.disabledCommands) { String normalizedDisabled = normalizeCommandName(disabledCommand); if (normalizedDisabled == null) { continue; @@ -80,7 +80,7 @@ public String disabledCommandKey(String input) { } public boolean hasDisabledCommands() { - return config.disabledCommands != null && !config.disabledCommands.isEmpty(); + return config.runtime.disabledCommands != null && !config.runtime.disabledCommands.isEmpty(); } public String normalizeCommandName(String commandName) { @@ -100,7 +100,7 @@ private boolean isExplicitlyDisabled(String normalizedCommandName) { return false; } - for (String disabledCommand : config.disabledCommands) { + for (String disabledCommand : config.runtime.disabledCommands) { String normalizedDisabled = normalizeCommandName(disabledCommand); if (normalizedCommandName.equals(normalizedDisabled)) { return true; diff --git a/src/main/java/org/xcore/plugin/command/CloudCommandRegistrar.java b/src/main/java/org/xcore/plugin/command/CloudCommandRegistrar.java index 93ac3247..8e99055d 100644 --- a/src/main/java/org/xcore/plugin/command/CloudCommandRegistrar.java +++ b/src/main/java/org/xcore/plugin/command/CloudCommandRegistrar.java @@ -8,7 +8,7 @@ import org.xcore.plugin.command.controller.CloudServerController; import org.xcore.plugin.command.controller.client.EventController; import org.xcore.plugin.command.controller.client.HexedController; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.util.List; @@ -16,13 +16,13 @@ public class CloudCommandRegistrar { private final CloudService cloud; - private final Config config; + private final TomlXcoreConfig config; private final List clientControllers; private final List serverControllers; @Inject public CloudCommandRegistrar(CloudService cloud, - Config config, + TomlXcoreConfig config, List clientControllers, List serverControllers) { this.cloud = cloud; @@ -42,13 +42,21 @@ public void init() { private boolean shouldRegister(CloudClientController controller) { if (controller instanceof HexedController) { - return config.isMiniHexed(); + return isMiniHexedServer(); } if (controller instanceof EventController) { - return config.isEvent(); + return isEventServer(); } return true; } + + private boolean isMiniHexedServer() { + return "mini-hexed".equals(config.server.name); + } + + private boolean isEventServer() { + return "event".equals(config.server.name); + } } diff --git a/src/main/java/org/xcore/plugin/command/controller/client/AuthController.java b/src/main/java/org/xcore/plugin/command/controller/client/AuthController.java index d9205d8e..46f72279 100644 --- a/src/main/java/org/xcore/plugin/command/controller/client/AuthController.java +++ b/src/main/java/org/xcore/plugin/command/controller/client/AuthController.java @@ -2,18 +2,16 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.incendo.cloud.annotations.Command; import org.incendo.cloud.annotations.Argument; +import org.incendo.cloud.annotations.Command; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudClientController; -import org.xcore.plugin.config.Config; import org.xcore.plugin.database.repository.AdminDataRepository; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.service.DiscordAdminAccessService; -import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; import static com.ospx.flubundle.Bundle.args; @@ -24,22 +22,16 @@ public class AuthController implements CloudClientController { private final AdminDataRepository adminDataRepository; private final SessionService sessionService; - private final NetworkService network; - private final Config config; private final PlayerDisplayService playerDisplayService; private final DiscordAdminAccessService discordAdminAccessService; @Inject public AuthController(AdminDataRepository adminDataRepository, SessionService sessionService, - NetworkService network, - Config config, PlayerDisplayService playerDisplayService, DiscordAdminAccessService discordAdminAccessService) { this.adminDataRepository = adminDataRepository; this.sessionService = sessionService; - this.network = network; - this.config = config; this.playerDisplayService = playerDisplayService; this.discordAdminAccessService = discordAdminAccessService; } @@ -51,7 +43,6 @@ public void login(XCoreSender sender, @Argument("password") String password) { PlayerData data = session.data; Localization local = session.locale(); - if (password.length() < 4) { local.send("error-admin-password-too-short", args()); return; diff --git a/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java b/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java index 647c610b..e5d5f764 100644 --- a/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java +++ b/src/main/java/org/xcore/plugin/command/controller/client/BadgeController.java @@ -6,7 +6,7 @@ import org.incendo.cloud.annotations.Command; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudClientController; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.player.Badge; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PlayerDisplayService; @@ -24,10 +24,10 @@ public class BadgeController implements CloudClientController { private final PlayerMenu playerMenu; private final PlayerDisplayService playerDisplayService; private final NetworkService network; - private final Config config; + private final TomlXcoreConfig config; @Inject - public BadgeController(Config config, + public BadgeController(TomlXcoreConfig config, SessionService sessionService, PlayerMenu playerMenu, PlayerDisplayService playerDisplayService, @@ -53,7 +53,7 @@ public void clear(XCoreSender sender) { sessionService.setActiveBadge(session, ""); playerDisplayService.refresh(session); - network.post(new PlayerActiveBadgeChangedCommandV1(session.data.uuid, session.data.activeBadge, config.server)); + network.post(new PlayerActiveBadgeChangedCommandV1(session.data.uuid, session.data.activeBadge, config.server.name)); session.locale().send("badge-clear-success", args()); } @@ -80,7 +80,7 @@ public void set(XCoreSender sender, @Argument("id") String id) { sessionService.setActiveBadge(session, badge.id()); playerDisplayService.refresh(session); - network.post(new PlayerActiveBadgeChangedCommandV1(session.data.uuid, session.data.activeBadge, config.server)); + network.post(new PlayerActiveBadgeChangedCommandV1(session.data.uuid, session.data.activeBadge, config.server.name)); session.locale().send("badge-set-success", args("badge", session.locale().t(badge.nameKey()))); } } diff --git a/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java b/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java index cfe2b3f3..78c2c499 100644 --- a/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java +++ b/src/main/java/org/xcore/plugin/command/controller/client/SocialController.java @@ -14,8 +14,8 @@ import org.xcore.plugin.cloud.annotation.RequiresMuteCheck; import org.xcore.plugin.cloud.annotation.RequiresPlayTime; import org.xcore.plugin.command.controller.CloudClientController; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.localization.TranslatorLanguagesProvider; import org.xcore.plugin.model.PlayerData; @@ -34,10 +34,9 @@ public class SocialController implements CloudClientController { private final SessionService sessionService; private final NetworkService network; - private final Config config; + private final TomlXcoreConfig config; private final GlobalConfig globalConfig; private final TranslatorLanguagesProvider translatorLanguagesProvider; - private final ChatFormatService chatFormatService; private final TranslatorService translatorService; private final DiscordLinkService discordLinkService; private final DiscordMenu discordMenu; @@ -45,7 +44,7 @@ public class SocialController implements CloudClientController { @Inject public SocialController(SessionService sessionService, NetworkService network, - Config config, + TomlXcoreConfig config, GlobalConfig globalConfig, TranslatorLanguagesProvider translatorLanguagesProvider, ChatFormatService chatFormatService, @@ -57,7 +56,6 @@ public SocialController(SessionService sessionService, this.config = config; this.globalConfig = globalConfig; this.translatorLanguagesProvider = translatorLanguagesProvider; - this.chatFormatService = chatFormatService; this.translatorService = translatorService; this.discordLinkService = discordLinkService; this.discordMenu = discordMenu; @@ -79,12 +77,12 @@ public void globalChat(XCoreSender sender, @Argument("message") @Greedy String m network.post(new ChatGlobalV1( session.player.coloredName(), message, - config.server + config.server.name )); network.post(new ChatMessageV1( session.player.plainName(), - "[" + config.server + "] " + message.replace("`", "*"), + "[" + config.server.name + "] " + message.replace("`", "*"), "global" )); } diff --git a/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java b/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java index 031d8dfb..1734bf1b 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/BadgeAdminController.java @@ -7,7 +7,7 @@ import org.incendo.cloud.annotations.Command; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudServerController; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.player.Badge; @@ -32,14 +32,14 @@ public class BadgeAdminController implements CloudServerController { private final NetworkService network; private final PlayerDisplayService playerDisplayService; private final PlayerDataRepository playerDataRepository; - private final Config config; + private final TomlXcoreConfig config; @Inject public BadgeAdminController(SessionService sessionService, NetworkService network, PlayerDisplayService playerDisplayService, PlayerDataRepository playerDataRepository, - Config config) { + TomlXcoreConfig config) { this.sessionService = sessionService; this.network = network; this.playerDisplayService = playerDisplayService; @@ -124,7 +124,7 @@ private void changeBadge(XCoreSender sender, String playerRef, String badgeId, b target.uuid, updatedActiveBadge, List.copyOf(updatedBadges), - config.server + config.server.name )); if (grant) { Log.info("Granted badge '@' to @ (#@).", badge.id(), target.nickname, target.pid); diff --git a/src/main/java/org/xcore/plugin/command/controller/server/DataController.java b/src/main/java/org/xcore/plugin/command/controller/server/DataController.java index 641ea2b6..9bfcb01c 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/DataController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/DataController.java @@ -14,10 +14,10 @@ import org.incendo.cloud.annotations.CommandDescription; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudServerController; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.ServerLocalConfigPathEditor; import org.xcore.plugin.config.ServerLocalConfigTomlRenderer; import org.xcore.plugin.config.ServerLocalConfigTomlStore; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.FindService; @@ -29,7 +29,7 @@ public class DataController implements CloudServerController { private static final String DISABLED_FEATURES_PATH = "runtime.disabled_features"; private final PlayerDataRepository playerDataRepository; - private Config config; + private final TomlXcoreConfig config; private final Gson prettyGson; private final FindService find; private final TopMenuCacheService topMenuCacheService; @@ -39,7 +39,7 @@ public class DataController implements CloudServerController { @Inject public DataController(PlayerDataRepository playerDataRepository, - Config config, + TomlXcoreConfig config, @Named("pretty") Gson prettyGson, FindService find, TopMenuCacheService topMenuCacheService, @@ -57,7 +57,7 @@ public DataController(PlayerDataRepository playerDataRepository, } public DataController(PlayerDataRepository playerDataRepository, - Config config, + TomlXcoreConfig config, Gson prettyGson, FindService find, ServerLocalConfigPathEditor pathEditor, @@ -82,7 +82,7 @@ public void xconfigEdit(XCoreSender sender, return; } - Config updated; + TomlXcoreConfig updated; try { updated = pathEditor.update(config, field, value); } catch (IllegalArgumentException e) { @@ -101,7 +101,8 @@ public void xconfigEdit(XCoreSender sender, Log.err("Failed to persist config change for field '@': @", field, e.getMessage()); return; } - config = updated; + + applyUpdatedConfig(updated); Log.info("Config field '@' updated.", field); } @@ -167,4 +168,16 @@ private void modifyJson(JsonValue jfield, String value) { case doubleValue -> jfield.set(Double.parseDouble(value), null); } } + + private void applyUpdatedConfig(TomlXcoreConfig updated) { + config.version = updated.version; + config.server = updated.server; + config.paths = updated.paths; + config.discord = updated.discord; + config.transport = updated.transport; + config.runtime = updated.runtime; + config.eventHub = updated.eventHub; + config.translation = updated.translation; + config.ipReputation = updated.ipReputation; + } } diff --git a/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java b/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java index d1fe1cf1..e96f520a 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/MaintainController.java @@ -17,8 +17,8 @@ import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudServerController; import org.xcore.plugin.common.PluginState; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.ServerLocalConfigTomlStore; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.enums.Feature; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; @@ -48,7 +48,7 @@ public class MaintainController implements CloudServerController { private final PlayerDataRepository playerDataRepository; private final PluginState pluginState; private final SessionService sessionService; - private final Config config; + private final TomlXcoreConfig serverLocalConfig; private final RuntimeToggleConfigService toggleConfigService; private final MapIdentityAuditService mapIdentityAuditService; private final TopMenuCacheService topMenuCacheService; @@ -60,17 +60,17 @@ public MaintainController(NetworkService network, SessionService sessionService, MapIdentityAuditService mapIdentityAuditService, TopMenuCacheService topMenuCacheService, - Config config, + TomlXcoreConfig serverLocalConfig, ServerLocalConfigTomlStore tomlStore, @Named("pretty") Gson prettyGson) { this.network = network; this.playerDataRepository = playerDataRepository; this.pluginState = pluginState; this.sessionService = sessionService; - this.config = config; + this.serverLocalConfig = serverLocalConfig; this.mapIdentityAuditService = mapIdentityAuditService; this.topMenuCacheService = topMenuCacheService; - this.toggleConfigService = new RuntimeToggleConfigService(config, tomlStore); + this.toggleConfigService = new RuntimeToggleConfigService(serverLocalConfig, tomlStore); } public MaintainController(NetworkService network, @@ -78,10 +78,10 @@ public MaintainController(NetworkService network, PluginState pluginState, SessionService sessionService, MapIdentityAuditService mapIdentityAuditService, - Config config, + TomlXcoreConfig serverLocalConfig, ServerLocalConfigTomlStore tomlStore, Gson prettyGson) { - this(network, playerDataRepository, pluginState, sessionService, mapIdentityAuditService, null, config, tomlStore, prettyGson); + this(network, playerDataRepository, pluginState, sessionService, mapIdentityAuditService, null, serverLocalConfig, tomlStore, prettyGson); } @Command("exit") @@ -164,7 +164,7 @@ public void deleteBots(XCoreSender sender) { if (deleted > 0 && topMenuCacheService != null) { topMenuCacheService.invalidateAll(); } - network.post(new PlayerDataCacheReloadCommandV1(config.server)); + network.post(new PlayerDataCacheReloadCommandV1(serverLocalConfig.server.name)); PLog.info("Deleted @ bots from database.", deleted); } diff --git a/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java b/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java index 80b71a72..9754f1e1 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigService.java @@ -1,7 +1,7 @@ package org.xcore.plugin.command.controller.server; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.ServerLocalConfigTomlStore; +import org.xcore.plugin.config.TomlXcoreConfig; import java.util.HashSet; import java.util.Locale; @@ -17,10 +17,10 @@ enum ToggleTarget { record ToggleMutationResult(boolean changed, String value) { } - private final Config config; + private final TomlXcoreConfig config; private final ServerLocalConfigTomlStore tomlStore; - RuntimeToggleConfigService(Config config, ServerLocalConfigTomlStore tomlStore) { + RuntimeToggleConfigService(TomlXcoreConfig config, ServerLocalConfigTomlStore tomlStore) { this.config = config; this.tomlStore = tomlStore; } @@ -77,8 +77,8 @@ String extractRootCommand(String normalizedCommand) { private Set values(ToggleTarget target) { return switch (target) { - case COMMAND -> config.disabledCommands; - case FEATURE -> config.disabledFeatures; + case COMMAND -> config.runtime.disabledCommands; + case FEATURE -> config.runtime.disabledFeatures; }; } @@ -91,8 +91,8 @@ private Set mutableSet(ToggleTarget target) { } switch (target) { - case COMMAND -> config.disabledCommands = current; - case FEATURE -> config.disabledFeatures = current; + case COMMAND -> config.runtime.disabledCommands = current; + case FEATURE -> config.runtime.disabledFeatures = current; } return current; diff --git a/src/main/java/org/xcore/plugin/command/controller/server/TranslationStatsController.java b/src/main/java/org/xcore/plugin/command/controller/server/TranslationStatsController.java index 67f5e8ee..9cc8f4fd 100644 --- a/src/main/java/org/xcore/plugin/command/controller/server/TranslationStatsController.java +++ b/src/main/java/org/xcore/plugin/command/controller/server/TranslationStatsController.java @@ -7,8 +7,8 @@ import org.incendo.cloud.annotations.CommandDescription; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.command.controller.CloudServerController; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationMetricsService; import java.util.Locale; @@ -17,12 +17,12 @@ @Singleton public class TranslationStatsController implements CloudServerController { - private final Config config; + private final TomlXcoreConfig config; private final GlobalConfig globalConfig; private final TranslationMetricsService translationMetricsService; @Inject - public TranslationStatsController(Config config, + public TranslationStatsController(TomlXcoreConfig config, GlobalConfig globalConfig, TranslationMetricsService translationMetricsService) { this.config = config; @@ -33,7 +33,7 @@ public TranslationStatsController(Config config, @Command("trstats") @CommandDescription("Shows translation pipeline metrics and per-provider statistics.") public void translationStats(XCoreSender sender) { - Log.info("Translation stats for server '@':", config.server); + Log.info("Translation stats for server '@':", config.server.name); Log.info(" Pipeline enabled: @", config.translation.enabled); Log.info(" Pipeline: @", String.join(" -> ", config.translation.pipeline)); diff --git a/src/main/java/org/xcore/plugin/config/ConfigFactory.java b/src/main/java/org/xcore/plugin/config/ConfigFactory.java index b03342af..73ca0a1a 100644 --- a/src/main/java/org/xcore/plugin/config/ConfigFactory.java +++ b/src/main/java/org/xcore/plugin/config/ConfigFactory.java @@ -20,11 +20,18 @@ public Fi legacyXcoreJsonFile() { } @Bean - public Config config(@Named("pretty") Gson gson) { + public TomlXcoreConfig serverLocalConfig(@Named("pretty") Gson gson) { var result = ConfigTomlLoader.loadXcoreConfig(dataDirectory, gson); logSource("Config", result.file, result.source); - Config config = result.config; + TomlXcoreConfig config = result.config; + config.normalize(); + return config; + } + + @Bean + public Config config(TomlXcoreConfig serverLocalConfig) { + Config config = ConfigTomlMapper.toConfig(serverLocalConfig); config.normalize(); return config; } diff --git a/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java b/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java index 1cb7baac..d2c27a72 100644 --- a/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java +++ b/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java @@ -19,7 +19,7 @@ * *

Resolution order for each config:

*
    - *
  1. If the TOML file exists, load it via Jackson TOML and map to the legacy model.
  2. + *
  3. If the TOML file exists, load it via Jackson TOML.
  4. *
  5. Else if the legacy JSON file exists, migrate it to TOML, back up the JSON file, then reload from TOML.
  6. *
  7. Else write a commented default TOML template, then load it.
  8. *
@@ -57,7 +57,7 @@ public enum Source { * Result of a config load operation, carrying the resolved object, the * source that was used, and the file that was read or created. * - * @param the configuration type, either {@link Config} or {@link GlobalConfig} + * @param the configuration type, either {@link TomlXcoreConfig} or {@link GlobalConfig} */ public static final class LoadResult { public final T config; @@ -154,18 +154,18 @@ static void resetBackupTimestampSupplier() { /** * Loads the server-local configuration following the TOML-first resolution order. * - *

The returned {@link Config} is fully normalized regardless of source.

+ *

The returned {@link TomlXcoreConfig} is fully normalized regardless of source.

* * @param dataDirectory the Mindustry data directory * @param gson the Gson instance used for legacy JSON fallback - * @return a {@link LoadResult} containing the resolved {@link Config} + * @return a {@link LoadResult} containing the resolved {@link TomlXcoreConfig} */ - public static LoadResult loadXcoreConfig(Fi dataDirectory, Gson gson) { + public static LoadResult loadXcoreConfig(Fi dataDirectory, Gson gson) { Fi tomlFile = resolveXcoreToml(dataDirectory); if (tomlFile.exists()) { TomlXcoreConfig toml = readToml(tomlFile, TomlXcoreConfig.class); - Config config = ConfigTomlMapper.toConfig(toml); - return new LoadResult<>(config, Source.TOML, tomlFile); + toml.normalize(); + return new LoadResult<>(toml, Source.TOML, tomlFile); } Fi jsonFile = resolveLegacyXcoreJson(dataDirectory); @@ -175,8 +175,8 @@ public static LoadResult loadXcoreConfig(Fi dataDirectory, Gson gson) { ConfigTomlTemplateWriter.writeDefaultXcoreToml(tomlFile); TomlXcoreConfig toml = readToml(tomlFile, TomlXcoreConfig.class); - Config config = ConfigTomlMapper.toConfig(toml); - return new LoadResult<>(config, Source.DEFAULT_TEMPLATE, tomlFile); + toml.normalize(); + return new LoadResult<>(toml, Source.DEFAULT_TEMPLATE, tomlFile); } /** @@ -218,10 +218,13 @@ private static Fi resolveGlobalDir(String globalConfigDirectory) { : globalConfigDirectory); } - private static LoadResult migrateLegacyXcoreConfig(Fi jsonFile, Fi tomlFile, Gson gson) { + private static LoadResult migrateLegacyXcoreConfig(Fi jsonFile, Fi tomlFile, Gson gson) { Config legacyConfig; try (var reader = jsonFile.reader()) { legacyConfig = gson.fromJson(reader, Config.class); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to read legacy JSON from " + jsonFile.absolutePath() + ": " + e.getMessage(), e); } if (legacyConfig == null) { legacyConfig = new Config(); @@ -232,14 +235,18 @@ private static LoadResult migrateLegacyXcoreConfig(Fi jsonFile, Fi tomlF writeToml(tomlFile, migratedToml); Fi backupFile = backupLegacyFile(jsonFile); - Config migratedConfig = ConfigTomlMapper.toConfig(readToml(tomlFile, TomlXcoreConfig.class)); - return new LoadResult<>(migratedConfig, Source.MIGRATED, tomlFile, backupFile); + TomlXcoreConfig reloadedToml = readToml(tomlFile, TomlXcoreConfig.class); + reloadedToml.normalize(); + return new LoadResult<>(reloadedToml, Source.MIGRATED, tomlFile, backupFile); } private static LoadResult migrateLegacySecretsConfig(Fi jsonFile, Fi tomlFile, Gson gson) { GlobalConfig legacyGlobal; try (var reader = jsonFile.reader()) { legacyGlobal = gson.fromJson(reader, GlobalConfig.class); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to read legacy JSON from " + jsonFile.absolutePath() + ": " + e.getMessage(), e); } if (legacyGlobal == null) { legacyGlobal = new GlobalConfig(); diff --git a/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java b/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java index 152374e1..0dca5211 100644 --- a/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java @@ -12,8 +12,7 @@ /** * Updates server-local config values using either legacy flat field names or - * TOML-oriented dotted paths, then maps the structured TOML view back into the - * legacy runtime {@link Config} model. + * TOML-oriented dotted paths. */ public final class ServerLocalConfigPathEditor { private static final Map PATH_BINDINGS = createPathBindings(); @@ -24,7 +23,7 @@ public ServerLocalConfigPathEditor(Gson prettyGson) { this.prettyGson = Objects.requireNonNull(prettyGson, "prettyGson must not be null"); } - public Config update(Config config, String fieldPath, String value) { + public TomlXcoreConfig update(TomlXcoreConfig config, String fieldPath, String value) { Objects.requireNonNull(config, "config must not be null"); Objects.requireNonNull(fieldPath, "fieldPath must not be null"); Objects.requireNonNull(value, "value must not be null"); @@ -34,8 +33,10 @@ public Config update(Config config, String fieldPath, String value) { return null; } - TomlXcoreConfig toml = ConfigTomlMapper.toTomlXcoreConfig(config); - JsonObject root = JsonParser.parseString(prettyGson.toJson(toml)).getAsJsonObject(); + TomlXcoreConfig workingCopy = prettyGson.fromJson(prettyGson.toJson(config), TomlXcoreConfig.class); + workingCopy.normalize(); + + JsonObject root = JsonParser.parseString(prettyGson.toJson(workingCopy)).getAsJsonObject(); PathLocation target = resolvePath(root, binding.canonicalPath()); if (target == null) { return null; @@ -44,7 +45,8 @@ public Config update(Config config, String fieldPath, String value) { binding.valueType().apply(target.parent(), target.key(), value, prettyGson); TomlXcoreConfig updatedToml = prettyGson.fromJson(root, TomlXcoreConfig.class); - return ConfigTomlMapper.toConfig(updatedToml); + updatedToml.normalize(); + return updatedToml; } public static IllegalArgumentException invalidValue(String fieldPath, String expected, String value, Throwable cause) { diff --git a/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java index 0c1a4f8f..4e3753ad 100644 --- a/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java @@ -8,17 +8,16 @@ import java.util.Objects; /** - * Renders the server-local runtime {@link Config} as a TOML-shaped view for + * Renders the server-local runtime config as a TOML-shaped view for * operator-facing inspection commands such as {@code xconfig}. */ public final class ServerLocalConfigTomlRenderer { - public String render(Config config) { + public String render(TomlXcoreConfig config) { Objects.requireNonNull(config, "config must not be null"); - - TomlXcoreConfig toml = ConfigTomlMapper.toTomlXcoreConfig(config); + config.normalize(); try { - return createTomlMapper().writeValueAsString(toml).trim(); + return createTomlMapper().writeValueAsString(config).trim(); } catch (IOException e) { throw new IllegalStateException("Failed to render server-local config as TOML: " + e.getMessage(), e); } diff --git a/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java index b60c7f09..bc6bddd4 100644 --- a/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java @@ -10,13 +10,12 @@ import java.util.Objects; /** - * Focused helper that persists the server-local {@link Config} runtime model - * back to {@code xcore.toml}. + * Focused helper that persists the server-local TOML runtime owner back to + * {@code xcore.toml}. * - *

This helper keeps TOML write logic out of controllers and does not - * touch global/secrets persistence. It uses the existing - * {@link ConfigTomlMapper#toTomlXcoreConfig(Config)} mapping so that - * written values match the startup TOML schema.

+ *

This helper keeps TOML write logic out of controllers and does not touch + * global/secrets persistence. {@link TomlXcoreConfig} is the direct helper + * boundary.

*/ public final class ServerLocalConfigTomlStore { private final Fi tomlFile; @@ -26,20 +25,20 @@ public ServerLocalConfigTomlStore(Fi tomlFile) { } /** - * Writes the given {@link Config} to the configured {@code xcore.toml} file. + * Writes the given {@link TomlXcoreConfig} to the configured + * {@code xcore.toml} file. * - *

The config is normalized and mapped to {@link TomlXcoreConfig} before - * writing so the output matches the startup TOML schema.

+ *

The config is normalized before writing so the output matches the + * startup TOML schema.

* - * @param config the runtime config to persist; must not be {@code null} + * @param config the structured runtime config to persist; must not be {@code null} * @throws NullPointerException if {@code config} is {@code null} * @throws IllegalStateException if writing fails */ - public void write(Config config) { + public void write(TomlXcoreConfig config) { Objects.requireNonNull(config, "config must not be null"); - - TomlXcoreConfig toml = ConfigTomlMapper.toTomlXcoreConfig(config); - writeToml(tomlFile, toml); + config.normalize(); + writeToml(tomlFile, config); } /** diff --git a/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java b/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java index 9028a8b3..97892144 100644 --- a/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java +++ b/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java @@ -173,8 +173,8 @@ public static class ProviderConfig { public String type = "google"; public boolean enabled = true; public String apiKey = ""; - public String baseUrl = ""; - public String model = ""; + public String baseUrl = "https://api.openai.com/v1"; + public String model = "gpt-5.4"; public String apiMode = ""; public String organization = ""; public String project = ""; @@ -187,6 +187,12 @@ public void normalize() { if (type == null || type.isBlank()) { type = "google"; } + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = "https://api.openai.com/v1"; + } + if (model == null || model.isBlank()) { + model = "gpt-5.4"; + } if (apiMode != null && !apiMode.isBlank()) { apiMode = apiMode.trim().toLowerCase(); } diff --git a/src/main/java/org/xcore/plugin/database/migration/MigrationService.java b/src/main/java/org/xcore/plugin/database/migration/MigrationService.java index 4639d24f..89f3747f 100644 --- a/src/main/java/org/xcore/plugin/database/migration/MigrationService.java +++ b/src/main/java/org/xcore/plugin/database/migration/MigrationService.java @@ -10,7 +10,7 @@ import com.mongodb.client.model.UpdateOptions; import jakarta.inject.Singleton; import org.bson.Document; -import org.xcore.plugin.config.Config; // Твій звичайний конфіг з назвою сервера +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.config.GlobalConfig; import java.util.Comparator; @@ -20,10 +20,10 @@ public class MigrationService { private final MongoDatabase database; private final GlobalConfig globalConfig; - private final Config config; + private final TomlXcoreConfig config; private final List migrations; - public MigrationService(MongoDatabase database, GlobalConfig globalConfig, Config config, List migrations) { + public MigrationService(MongoDatabase database, GlobalConfig globalConfig, TomlXcoreConfig config, List migrations) { this.database = database; this.globalConfig = globalConfig; this.config = config; @@ -68,7 +68,7 @@ public boolean run() { } } else { Log.warn("[Migrations] Another server (@) is currently performing migrations.", getLockOwner(settings)); - Log.warn("[Migrations] This server (@) will enter Read-Only mode until restart.", config.server); + Log.warn("[Migrations] This server (@) will enter Read-Only mode until restart.", config.server.name); globalConfig.isDataBaseReadOnly = true; return true; } @@ -110,13 +110,13 @@ private boolean tryLock(MongoCollection settings) { ), Updates.combine( Updates.set("locked", true), - Updates.set("locked_by", config.server), + Updates.set("locked_by", config.server.name), Updates.set("locked_at", now) ), new FindOneAndUpdateOptions().upsert(true).returnDocument(ReturnDocument.AFTER) ); - return result != null && result.getString("locked_by").equals(config.server); + return result != null && result.getString("locked_by").equals(config.server.name); } private void releaseLock(MongoCollection settings) { diff --git a/src/main/java/org/xcore/plugin/event/TransportService.java b/src/main/java/org/xcore/plugin/event/TransportService.java index 8de469eb..4fbdc112 100644 --- a/src/main/java/org/xcore/plugin/event/TransportService.java +++ b/src/main/java/org/xcore/plugin/event/TransportService.java @@ -12,7 +12,7 @@ import mindustry.net.Administration; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerHeartbeatV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.event.transport.ChatTransportHandler; import org.xcore.plugin.event.transport.DiscordLinkTransportHandler; import org.xcore.plugin.event.transport.MapTransportHandler; @@ -35,7 +35,7 @@ public class TransportService { private final ModerationTransportHandler moderationTransportHandler; private final MapTransportHandler mapTransportHandler; private final NetworkService network; - private final Config config; + private final TomlXcoreConfig config; private volatile String cachedPublicHost; private volatile long nextPublicHostResolveAttemptAtMs; @@ -45,7 +45,7 @@ public TransportService(ChatTransportHandler chatTransportHandler, ModerationTransportHandler moderationTransportHandler, MapTransportHandler mapTransportHandler, NetworkService network, - Config config) { + TomlXcoreConfig config) { this.chatTransportHandler = chatTransportHandler; this.discordLinkTransportHandler = discordLinkTransportHandler; this.moderationTransportHandler = moderationTransportHandler; @@ -60,15 +60,15 @@ public void init() { registerListeners(); Events.on(EventType.ServerLoadEvent.class, event -> { - network.post(new ServerActionV1("Server loaded", config.server)); + network.post(new ServerActionV1("Server loaded", config.server.name)); Timer.schedule(() -> { try { network.post(new ServerHeartbeatV1( - config.server, - config.discordChannelId, + config.server.name, + config.discord.channelId, Groups.player.size(), - config.getNoAdminPlayerLimit(), + config.server.playerLimit + Groups.player.count(p -> p.admin), Version.buildString(), resolveHostAddress(), Administration.Config.port.num() @@ -140,11 +140,11 @@ protected long hostResolutionFailureBackoffMs() { } private String configuredPublicHostOverride() { - if (config.publicHostOverride == null) { + if (config.server.publicHostOverride == null) { return null; } - String normalized = config.publicHostOverride.trim(); + String normalized = config.server.publicHostOverride.trim(); return normalized.isEmpty() ? null : normalized; } } diff --git a/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java b/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java index 2cd196bb..7b9dd355 100644 --- a/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java +++ b/src/main/java/org/xcore/plugin/event/handler/ConnectionHandler.java @@ -9,8 +9,8 @@ import mindustry.gen.Call; import mindustry.gen.Player; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerJoinLeaveV1; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.AdminDataRepository; import org.xcore.plugin.gamemode.hexed.HexedRanks; import org.xcore.plugin.localization.Localization; @@ -35,7 +35,7 @@ public class ConnectionHandler { private final SessionService sessionService; private final AdminDataRepository adminDataRepository; private final NetworkService network; - private final Config config; + private final TomlXcoreConfig config; private final GlobalConfig globalConfig; private final VoteService voteService; private final PrivateMessageService privateMessageService; @@ -47,7 +47,7 @@ public class ConnectionHandler { public ConnectionHandler(SessionService sessionService, AdminDataRepository adminDataRepository, NetworkService network, - Config config, + TomlXcoreConfig config, GlobalConfig globalConfig, VoteService voteService, PrivateMessageService privateMessageService, @@ -129,7 +129,7 @@ public void onPlayerJoin(PlayerJoin event) { "pid", data.pid)); network.post(new PlayerJoinLeaveV1( player.plainName() + " #" + data.pid, - config.server, + config.server.name, true) ); } @@ -151,7 +151,7 @@ public void onPlayerLeave(PlayerLeave event) { network.post(new PlayerJoinLeaveV1( player.plainName() + " #" + data.pid, - config.server, + config.server.name, false) ); } diff --git a/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java b/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java index 5958e395..fd461393 100644 --- a/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java +++ b/src/main/java/org/xcore/plugin/event/handler/GameLifecycleHandler.java @@ -16,7 +16,7 @@ import mindustry.net.Packets; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.plugin.common.PluginState; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.enums.FinishReason; import org.xcore.plugin.service.GameDataService; @@ -34,7 +34,7 @@ public class GameLifecycleHandler { private final NetworkService network; - private final Config config; + private final TomlXcoreConfig config; private final Bundle bundle; private final SessionService sessionService; private final PluginState pluginState; @@ -42,7 +42,7 @@ public class GameLifecycleHandler { private final MapStatsService mapStatsService; public GameLifecycleHandler(NetworkService network, - Config config, + TomlXcoreConfig config, Bundle bundle, SessionService sessionService, PluginState pluginState, GameDataService gameDataService, @@ -87,14 +87,14 @@ public void onGameOver(GameOverEvent event) { "Game over! Reached wave @ with @ players online on map @.", state.wave, Groups.player.size(), Strings.capitalize(Strings.stripColors(state.map.name()))); - } else if (state.rules.pvp && !config.isMiniHexed()) { + } else if (state.rules.pvp && !"mini-hexed".equals(config.server.name)) { message = Strings.format( "Game over! Team @ is victorious with @ players online on map @.", event.winner.name, Groups.player.size(), Strings.capitalize(Strings.stripColors(state.map.name()))); } - network.post(new ServerActionV1(message, config.server)); + network.post(new ServerActionV1(message, config.server.name)); if (state.map != null && !state.isMenu()) { String fileName = state.map.file == null ? null : state.map.file.name(); diff --git a/src/main/java/org/xcore/plugin/event/handler/MapVoteHandler.java b/src/main/java/org/xcore/plugin/event/handler/MapVoteHandler.java index f6017a4c..37b23380 100644 --- a/src/main/java/org/xcore/plugin/event/handler/MapVoteHandler.java +++ b/src/main/java/org/xcore/plugin/event/handler/MapVoteHandler.java @@ -13,7 +13,7 @@ import mindustry.maps.Map; import mindustry.net.Packets; import mindustry.server.ServerControl; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.MapData; @@ -29,7 +29,7 @@ public class MapVoteHandler { private final Provider mapMenu; private final GameDataService gameDataService; private final EventDataRepository eventDataRepository; - private final Config config; + private final TomlXcoreConfig config; private final MapService mapService; @@ -38,7 +38,7 @@ public MapVoteHandler(MapDataRepository mapDataRepository, Provider mapMenu, GameDataService gameDataService, EventDataRepository eventDataRepository, - Config config, + TomlXcoreConfig config, MapService mapService) { this.mapDataRepository = mapDataRepository; this.mapMenu = mapMenu; @@ -72,7 +72,7 @@ public Cons getGameOverListener() { mapMenu.get().showGameOverMenu(mapData, nextMapData, event.winner); - gameDataService.startNewGame(nextMapData, state.rules.modeName, config.isEvent() ? eventDataRepository.findActive().orElse(null) : null); + gameDataService.startNewGame(nextMapData, state.rules.modeName, "event".equals(config.server.name) ? eventDataRepository.findActive().orElse(null) : null); Groups.player.each(gameDataService::addPlayer); ServerControl.instance.play(() -> { diff --git a/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java b/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java index a8b1cbed..95bb1b13 100644 --- a/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java +++ b/src/main/java/org/xcore/plugin/event/net/chat/ChatMessageHandler.java @@ -5,7 +5,7 @@ import jakarta.inject.Singleton; import mindustry.gen.Player; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.ChatFormatService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.SecurityService; @@ -14,7 +14,7 @@ @Singleton public class ChatMessageHandler { - private final Config config; + private final TomlXcoreConfig config; private final TranslatorService translatorService; private final NetworkService network; private final SecurityService securityService; @@ -22,7 +22,7 @@ public class ChatMessageHandler { private final VoteChatInterceptor voteChatInterceptor; @Inject - public ChatMessageHandler(Config config, + public ChatMessageHandler(TomlXcoreConfig config, TranslatorService translatorService, NetworkService network, SecurityService securityService, @@ -50,7 +50,7 @@ public String handle(Player author, String text) { author.sendMessage(chatFormatService.formatChat(author, text), author, text); translatorService.translate(author, text); - network.post(new ChatMessageV1(author.plainName(), text.replace("`", "*"), config.server)); + network.post(new ChatMessageV1(author.plainName(), text.replace("`", "*"), config.server.name)); return null; } } diff --git a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java index 850660cd..1e0f1329 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ChatTransportHandler.java @@ -7,7 +7,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.model.PrivateMessage; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.service.PrivateMessageService; @@ -22,13 +22,13 @@ public class ChatTransportHandler { private final NetworkService network; private final SessionService sessionService; private final PrivateMessageService privateMessageService; - private final Config config; + private final TomlXcoreConfig config; @Inject public ChatTransportHandler(NetworkService network, SessionService sessionService, PrivateMessageService privateMessageService, - Config config) { + TomlXcoreConfig config) { this.network = network; this.sessionService = sessionService; this.privateMessageService = privateMessageService; @@ -46,7 +46,7 @@ public void registerListeners() { }); network.subscribe(ChatDiscordIngressCommandV1.class, e -> { - if (!config.server.equals(e.server())) { + if (!config.server.name.equals(e.server())) { return; } @@ -58,7 +58,7 @@ public void registerListeners() { }); network.subscribe(ChatPrivateV1.class, e -> { - if (config.server.equals(e.server())) { + if (config.server.name.equals(e.server())) { return; } diff --git a/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java index f280139f..dbb477b6 100644 --- a/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandler.java @@ -2,7 +2,6 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; import org.xcore.plugin.service.DiscordLinkService; import org.xcore.plugin.service.NetworkService; import org.xcore.plugin.session.SessionService; @@ -17,17 +16,14 @@ public class DiscordLinkTransportHandler { private final NetworkService network; private final DiscordLinkService discordLinkService; private final SessionService sessionService; - private final Config config; @Inject public DiscordLinkTransportHandler(NetworkService network, DiscordLinkService discordLinkService, - SessionService sessionService, - Config config) { + SessionService sessionService) { this.network = network; this.discordLinkService = discordLinkService; this.sessionService = sessionService; - this.config = config; } public void registerListeners() { diff --git a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java index 558d01c9..b637394c 100644 --- a/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/MapTransportHandler.java @@ -10,7 +10,7 @@ import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; import org.xcore.protocol.generated.shared.MapFileSourceV1; import org.xcore.protocol.generated.shared.MapEntryV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.MapData; import org.xcore.plugin.service.MapService; @@ -29,13 +29,13 @@ public class MapTransportHandler { private final NetworkService network; - private final Config config; + private final TomlXcoreConfig config; private final MapService mapService; private final MapDataRepository mapDataRepository; @Inject public MapTransportHandler(NetworkService network, - Config config, + TomlXcoreConfig config, MapService mapService, MapDataRepository mapDataRepository) { this.network = network; @@ -46,7 +46,7 @@ public MapTransportHandler(NetworkService network, public void registerListeners() { network.subscribe(MapsListRequestV1.class, request -> { - if (!request.server().equals(config.server)) return; + if (!request.server().equals(config.server.name)) return; var customMaps = maps.customMaps(); String currentGameMode = state.rules.mode().name(); @@ -62,7 +62,7 @@ public void registerListeners() { }); network.subscribe(MapsRemoveRequestV1.class, request -> { - if (!request.server().equals(config.server)) return; + if (!request.server().equals(config.server.name)) return; var map = mapService.findMapByFileName(request.fileName()); if (map != null) { @@ -79,7 +79,7 @@ public void registerListeners() { }); network.subscribe(MapsLoadCommandV1.class, e -> { - if (!config.server.equals(e.server())) return; + if (!config.server.name.equals(e.server())) return; AtomicInteger counter = new AtomicInteger(); for (MapFileSourceV1 file : e.files()) { diff --git a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java index 4b20c055..2a4defab 100644 --- a/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java +++ b/src/main/java/org/xcore/plugin/event/transport/ModerationTransportHandler.java @@ -7,7 +7,7 @@ import mindustry.net.Administration; import mindustry.net.Packets; import mindustry.server.ServerControl; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.DiscordAdminAccessService; import org.xcore.plugin.service.NetworkService; @@ -35,14 +35,14 @@ public class ModerationTransportHandler { private final NetworkService network; private final SessionService sessionService; - private final Config config; + private final TomlXcoreConfig config; private final PlayerDisplayService playerDisplayService; private final DiscordAdminAccessService discordAdminAccessService; @Inject public ModerationTransportHandler(NetworkService network, SessionService sessionService, - Config config, + TomlXcoreConfig config, PlayerDisplayService playerDisplayService, DiscordAdminAccessService discordAdminAccessService) { this.network = network; @@ -135,8 +135,8 @@ public void registerListeners() { network.subscribe(ServerCommandExecuteCommandV1.class, e -> { if (!e.targetServers().isEmpty()) { if (e.exclusion()) { - if (e.targetServers().contains(config.server)) return; - } else if (!e.targetServers().contains(config.server)) { + if (e.targetServers().contains(config.server.name)) return; + } else if (!e.targetServers().contains(config.server.name)) { return; } } diff --git a/src/main/java/org/xcore/plugin/gamemode/hexed/HexedRanks.java b/src/main/java/org/xcore/plugin/gamemode/hexed/HexedRanks.java index 82b8b49b..0019f4f0 100644 --- a/src/main/java/org/xcore/plugin/gamemode/hexed/HexedRanks.java +++ b/src/main/java/org/xcore/plugin/gamemode/hexed/HexedRanks.java @@ -1,12 +1,12 @@ package org.xcore.plugin.gamemode.hexed; import mindustry.gen.Player; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.model.PlayerData; public class HexedRanks { - public static void updateRank(Player player, PlayerData data, Config config) { - if (!config.isMiniHexed() || player == null || data == null) return; + public static void updateRank(Player player, PlayerData data, TomlXcoreConfig config) { + if (!"mini-hexed".equals(config.server.name) || player == null || data == null) return; } public enum HexedRank { diff --git a/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java b/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java index cc319a77..6782be19 100644 --- a/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java +++ b/src/main/java/org/xcore/plugin/gamemode/hexed/MiniHexedService.java @@ -23,7 +23,7 @@ import mindustry.net.WorldReloader; import mindustry.world.blocks.storage.CoreBlock; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.session.ObserverService; import org.xcore.plugin.session.SessionService; @@ -55,7 +55,7 @@ public class MiniHexedService { private static final int INITIAL_WIN_SCORE = 2400; private static int winScore = INITIAL_WIN_SCORE; - private final Config config; + private final TomlXcoreConfig config; private final SessionService sessionService; private final PlayerDataRepository playerDataRepository; private final NetworkService network; @@ -69,7 +69,7 @@ public class MiniHexedService { private static boolean gameover = false; - public MiniHexedService(Config config, + public MiniHexedService(TomlXcoreConfig config, SessionService sessionService, PlayerDataRepository playerDataRepository, NetworkService networkService, @@ -95,7 +95,7 @@ public MiniHexedService(Config config, @PostConstruct public void init() { - if (!config.isMiniHexed()) return; + if (!"mini-hexed".equals(config.server.name)) return; leaderboardService.start((builder, player, locale) -> { var teams = Vars.state.teams.getActive().copy() @@ -295,7 +295,7 @@ private void endGame() { }); String rawMessage = generateMessage.get(new Localization(bundle)); - network.post(new ServerActionV1(Strings.stripColors(rawMessage), config.server)); + network.post(new ServerActionV1(Strings.stripColors(rawMessage), config.server.name)); Events.fire("hexed_world-reload"); Timer.schedule(this::reloadMap, 10); diff --git a/src/main/java/org/xcore/plugin/gamemode/laststanding/LastStanding.java b/src/main/java/org/xcore/plugin/gamemode/laststanding/LastStanding.java index 566291c2..fec06b40 100644 --- a/src/main/java/org/xcore/plugin/gamemode/laststanding/LastStanding.java +++ b/src/main/java/org/xcore/plugin/gamemode/laststanding/LastStanding.java @@ -10,7 +10,7 @@ import mindustry.game.EventType; import mindustry.game.Team; import mindustry.world.Block; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.gamemode.laststanding.LastStandingAi; import static arc.Core.app; @@ -25,16 +25,16 @@ public class LastStanding { Team.blue, Blocks.metalFloor4 ); - private final Config config; + private final TomlXcoreConfig config; @Inject - public LastStanding(Config config) { + public LastStanding(TomlXcoreConfig config) { this.config = config; } @PostConstruct public void init() { - if (!config.isLastStanding()) return; + if (!"the-last-standing".equals(config.server.name)) return; Events.on(EventType.CoreChangeEvent.class, event -> app.post(() -> spawnFloors.each((team, floor) -> { diff --git a/src/main/java/org/xcore/plugin/gamemode/pvp/MiniPvP.java b/src/main/java/org/xcore/plugin/gamemode/pvp/MiniPvP.java index a698c25c..99a0219a 100644 --- a/src/main/java/org/xcore/plugin/gamemode/pvp/MiniPvP.java +++ b/src/main/java/org/xcore/plugin/gamemode/pvp/MiniPvP.java @@ -11,7 +11,7 @@ import mindustry.game.Team; import mindustry.gen.Groups; import mindustry.world.blocks.storage.CoreBlock; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.LeaderboardService; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.session.ObserverService; @@ -27,7 +27,7 @@ public class MiniPvP { public final Seq defeatedPlayers = new Seq<>(); - private final Config config; + private final TomlXcoreConfig config; private final SessionService sessionService; private final PlayerDataRepository playerDataRepository; private final LeaderboardService leaderboardService; @@ -35,7 +35,7 @@ public class MiniPvP { private final ObserverService observerService; @Inject - public MiniPvP(Config config, + public MiniPvP(TomlXcoreConfig config, SessionService sessionService, PlayerDataRepository playerDataRepository, LeaderboardService leaderboardService, @@ -51,7 +51,7 @@ public MiniPvP(Config config, @PostConstruct public void init() { - if (!config.isMiniPvP()) return; + if (!"mini-pvp".equals(config.server.name)) return; leaderboardService.start((builder, player, locale) -> { Seq sorted = new Seq<>(); diff --git a/src/main/java/org/xcore/plugin/integration/Console.java b/src/main/java/org/xcore/plugin/integration/Console.java index ab6b80eb..a8c6ce4a 100644 --- a/src/main/java/org/xcore/plugin/integration/Console.java +++ b/src/main/java/org/xcore/plugin/integration/Console.java @@ -16,7 +16,7 @@ import org.jline.terminal.TerminalBuilder; import org.jline.widget.AutopairWidgets; import org.jspecify.annotations.NonNull; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.io.ByteArrayOutputStream; import java.io.PrintStream; @@ -28,7 +28,7 @@ @Singleton public class Console { - private final Config config; + private final TomlXcoreConfig config; private final CloudJLineCompleter cloudJLineCompleter; private final ServerControl serverControl; @@ -38,14 +38,14 @@ public class Console { private volatile boolean running = false; @Inject - public Console(Config config, CloudJLineCompleter cloudJLineCompleter) { // Внедряем абстракцию + public Console(TomlXcoreConfig config, CloudJLineCompleter cloudJLineCompleter) { // Внедряем абстракцию this.config = config; this.cloudJLineCompleter = cloudJLineCompleter; this.serverControl = ServerControl.instance; } @PostConstruct public void init() { - if (!config.consoleEnabled) return; + if (!config.server.consoleEnabled) return; ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); try { diff --git a/src/main/java/org/xcore/plugin/localization/TranslationProviderFactory.java b/src/main/java/org/xcore/plugin/localization/TranslationProviderFactory.java index 05495847..abff0581 100644 --- a/src/main/java/org/xcore/plugin/localization/TranslationProviderFactory.java +++ b/src/main/java/org/xcore/plugin/localization/TranslationProviderFactory.java @@ -4,8 +4,8 @@ import io.avaje.inject.Factory; import jakarta.inject.Inject; import org.xcore.plugin.common.PLog; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationSafetyService; import java.util.ArrayList; @@ -14,11 +14,11 @@ @Factory public class TranslationProviderFactory { - private final Config config; + private final TomlXcoreConfig config; private final GlobalConfig globalConfig; @Inject - public TranslationProviderFactory(Config config, GlobalConfig globalConfig) { + public TranslationProviderFactory(TomlXcoreConfig config, GlobalConfig globalConfig) { this.config = config; this.globalConfig = globalConfig; } diff --git a/src/main/java/org/xcore/plugin/map/SmartMapSelector.java b/src/main/java/org/xcore/plugin/map/SmartMapSelector.java index 760aab3c..1bc9b2b2 100644 --- a/src/main/java/org/xcore/plugin/map/SmartMapSelector.java +++ b/src/main/java/org/xcore/plugin/map/SmartMapSelector.java @@ -11,7 +11,7 @@ import mindustry.maps.Map; import mindustry.maps.Maps.MapProvider; import org.bson.types.ObjectId; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.EventData; @@ -24,7 +24,7 @@ public class SmartMapSelector implements MapProvider { private final Seq recentMaps = new Seq<>(); private static final int HISTORY_SIZE = 5; - private final Config config; + private final TomlXcoreConfig config; private final EventDataRepository eventDataRepository; private final MapDataRepository mapDataRepository; @@ -32,7 +32,7 @@ public class SmartMapSelector implements MapProvider { private final EventService eventService; @Inject - public SmartMapSelector(Config config, EventDataRepository eventDataRepository, MapDataRepository mapDataRepository, EventService eventService) { + public SmartMapSelector(TomlXcoreConfig config, EventDataRepository eventDataRepository, MapDataRepository mapDataRepository, EventService eventService) { this.config = config; this.eventDataRepository = eventDataRepository; this.mapDataRepository = mapDataRepository; @@ -41,7 +41,7 @@ public SmartMapSelector(Config config, EventDataRepository eventDataRepository, @Override public Map next(Gamemode mode, Map previous) { - if (config.isEvent()) { + if ("event".equals(config.server.name)) { eventService.checkTimedEvents(); var activeEventOpt = eventDataRepository.findActive(); @@ -59,9 +59,9 @@ public Map next(Gamemode mode, Map previous) { if (map != null) return map; } - if (config.isEventHubMap && config.eventHubMapID != null && !config.eventHubMapID.isEmpty()) { - if (ObjectId.isValid(config.eventHubMapID)) { - Map map = findMindustryMap(new ObjectId(config.eventHubMapID)); + if (config.eventHub.enabled && config.eventHub.mapId != null && !config.eventHub.mapId.isEmpty()) { + if (ObjectId.isValid(config.eventHub.mapId)) { + Map map = findMindustryMap(new ObjectId(config.eventHub.mapId)); if (map != null) return map; } else { Log.err("Invalid eventHubMapID"); diff --git a/src/main/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheck.java b/src/main/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheck.java index 6a8cdfd6..2a341c5a 100644 --- a/src/main/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheck.java +++ b/src/main/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheck.java @@ -6,7 +6,7 @@ import mindustry.net.NetConnection; import mindustry.net.Packets; import mindustry.net.Packets.ConnectPacket; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.security.ingress.AccessResult; import org.xcore.plugin.security.ingress.IngressCheck; @@ -19,16 +19,16 @@ @Singleton public class PlayerLimitCheck implements IngressCheck { - private final Config config; + private final TomlXcoreConfig config; @Inject - public PlayerLimitCheck(Config config) { + public PlayerLimitCheck(TomlXcoreConfig config) { this.config = config; } @Override public AccessResult check(NetConnection con, ConnectPacket packet) { - if (config.playerLimit <= 0) { + if (config.server.playerLimit <= 0) { return AccessResult.Allowed.INSTANCE; } @@ -37,7 +37,7 @@ public AccessResult check(NetConnection con, ConnectPacket packet) { return AccessResult.Allowed.INSTANCE; } - if (Groups.player.size() >= config.getNoAdminPlayerLimit()) { + if (Groups.player.size() >= noAdminPlayerLimit()) { return new AccessResult.Denied(Packets.KickReason.playerLimit.name(), false, 0); } @@ -53,4 +53,8 @@ public int priority() { public String name() { return "PlayerLimitCheck"; } -} \ No newline at end of file + + private int noAdminPlayerLimit() { + return config.server.playerLimit + Groups.player.count(player -> player.admin); + } +} diff --git a/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationService.java b/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationService.java index f42b5872..bab1f6f2 100644 --- a/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationService.java +++ b/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationService.java @@ -2,7 +2,7 @@ import jakarta.inject.Singleton; import org.xcore.plugin.common.PLog; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; /** * Concrete implementation of {@link IpReputationService} that orchestrates @@ -14,7 +14,7 @@ @Singleton public class IpReputationOrchestrationService implements IpReputationService { - private final Config config; + private final TomlXcoreConfig config; private final IpReputationAllowlist allowlist; private final IpReputationCache cache; private final IpReputationProvider provider; @@ -30,7 +30,7 @@ public class IpReputationOrchestrationService implements IpReputationService { * @param policy policy used to decide whether a given IpReputationResult constitutes a block */ public IpReputationOrchestrationService( - Config config, + TomlXcoreConfig config, IpReputationAllowlist allowlist, IpReputationCache cache, IpReputationProvider provider, diff --git a/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicy.java b/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicy.java index c5a12184..3747f126 100644 --- a/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicy.java +++ b/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicy.java @@ -1,7 +1,7 @@ package org.xcore.plugin.security.ingress.ipreputation; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; /** * Evaluates whether an IP reputation result should trigger a block @@ -13,14 +13,14 @@ @Singleton public class IpReputationPolicy { - private final Config.IpReputationConfig config; + private final TomlXcoreConfig.IpReputationConfig config; /** * Creates a new IpReputationPolicy using values from the provided application configuration. * * @param config the application configuration whose {@code ipReputation} subsection will be used by this policy */ - public IpReputationPolicy(Config config) { + public IpReputationPolicy(TomlXcoreConfig config) { this.config = config.ipReputation; } diff --git a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java index 3e233d39..847e7fc0 100644 --- a/src/main/java/org/xcore/plugin/service/DiscordLinkService.java +++ b/src/main/java/org/xcore/plugin/service/DiscordLinkService.java @@ -2,7 +2,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.protocol.generated.messages.discord.DiscordMessages.DiscordUnlinkCommandV1; @@ -25,7 +25,7 @@ public class DiscordLinkService { private final PlayerDataRepository playerDataRepository; private final SessionService sessionService; private final NetworkService networkService; - private final Config config; + private final TomlXcoreConfig config; private final DiscordAdminAccessService discordAdminAccessService; private final SecureRandom secureRandom = new SecureRandom(); @@ -33,9 +33,9 @@ public class DiscordLinkService { public DiscordLinkService(RedisDiscordLinkCodeStore discordLinkCodeStore, PlayerDataRepository playerDataRepository, SessionService sessionService, - NetworkService networkService, - Config config, - DiscordAdminAccessService discordAdminAccessService) { + NetworkService networkService, + TomlXcoreConfig config, + DiscordAdminAccessService discordAdminAccessService) { this.discordLinkCodeStore = discordLinkCodeStore; this.playerDataRepository = playerDataRepository; this.sessionService = sessionService; @@ -65,7 +65,7 @@ public LinkCodeResult createCode(Session session) { data.uuid, data.pid, data.nickname, - config.server, + config.server.name, now, expiresAt ); @@ -79,7 +79,7 @@ public LinkCodeResult createCode(Session session) { data.uuid, data.pid, data.nickname, - config.server, + config.server.name, now, expiresAt )); @@ -250,7 +250,7 @@ private void publishStatusChanged(PlayerData data, discordId, discordUsername, status, - config.server, + config.server.name, timestamp )); } @@ -277,7 +277,7 @@ private void publishAdminAccessChanged(String playerUuid, source, actor, reason, - config.server, + config.server.name, System.currentTimeMillis() )); } @@ -292,7 +292,7 @@ public DiscordUnlinkCommandV1 toUnlinkCommand(PlayerData data, String requestedB data.discordId, data.discordUsername, actor, - config.server, + config.server.name, System.currentTimeMillis() ); } diff --git a/src/main/java/org/xcore/plugin/service/GameDataService.java b/src/main/java/org/xcore/plugin/service/GameDataService.java index 13e06612..fb864624 100644 --- a/src/main/java/org/xcore/plugin/service/GameDataService.java +++ b/src/main/java/org/xcore/plugin/service/GameDataService.java @@ -6,7 +6,7 @@ import mindustry.gen.Groups; import mindustry.gen.Player; import mindustry.net.Administration; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.GameDataRepository; import org.xcore.plugin.model.*; import org.xcore.plugin.model.enums.FinishReason; @@ -21,7 +21,7 @@ @Singleton public class GameDataService { - private final Config config; + private final TomlXcoreConfig config; private final GameDataRepository gameDataRepository; private GameData currentBag; @@ -29,7 +29,7 @@ public class GameDataService { private final Map playerStatsCache = new HashMap<>(); @Inject - public GameDataService(Config config, GameDataRepository gameDataRepository) { + public GameDataService(TomlXcoreConfig config, GameDataRepository gameDataRepository) { this.config = config; this.gameDataRepository = gameDataRepository; } @@ -145,7 +145,7 @@ private VictoryType resolveVictoryType(Team winner) { private GameStatsCategory resolveStatsCategory(String mode, boolean eventGame) { var rules = state == null ? null : state.rules; - if (config.isMiniHexed() || configIsHexedMode(mode)) { + if (isMiniHexedServer() || configIsHexedMode(mode)) { return GameStatsCategory.HEXED; } if (eventGame) { @@ -182,6 +182,10 @@ private boolean configIsHexedMode(String mode) { return mode != null && mode.toLowerCase().contains("hexed"); } + private boolean isMiniHexedServer() { + return "mini-hexed".equals(config.server.name); + } + private String resolveServerName() { try { if (Administration.Config.serverName != null) { diff --git a/src/main/java/org/xcore/plugin/service/MapService.java b/src/main/java/org/xcore/plugin/service/MapService.java index 933054e3..c661502b 100644 --- a/src/main/java/org/xcore/plugin/service/MapService.java +++ b/src/main/java/org/xcore/plugin/service/MapService.java @@ -11,8 +11,8 @@ import mindustry.gen.Player; import mindustry.maps.Map; import org.xcore.plugin.common.TextUtils; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.EventData; @@ -40,7 +40,7 @@ public class MapService { private final EventDataRepository eventDataRepository; private final MapDataRepository mapDataRepository; private final SessionService sessionService; - private final Config config; + private final TomlXcoreConfig config; private final GlobalConfig globalConfig; private final VoteService voteService; private final VoteNewWaveFactory voteNewWaveFactory; @@ -51,7 +51,7 @@ public class MapService { public MapService(EventDataRepository eventDataRepository, MapDataRepository mapDataRepository, SessionService sessionService, - Config config, + TomlXcoreConfig config, GlobalConfig globalConfig, VoteService voteService, VoteNewWaveFactory voteNewWaveFactory, @@ -130,7 +130,7 @@ public T findInSeq(String nameOrIndex, Seq values, Boolf filter) { public void startRtvSession(Player player, Map target, boolean isManual, boolean forced) { var session = sessionService.get(player.uuid()); - if (config.isFeatureDisabled(Feature.RTV)) { + if (isFeatureDisabled(Feature.RTV)) { session.locale().send("error-feature-disabled"); return; } @@ -165,7 +165,7 @@ public void startRtvSession(Player player, Map target, boolean isManual, boolean public void startNewWaveSession(Player player, boolean forced) { var session = sessionService.get(player.uuid()); - if (config.isFeatureDisabled(Feature.VNW)) { + if (isFeatureDisabled(Feature.VNW)) { session.locale().send("error-feature-disabled"); return; } @@ -281,7 +281,7 @@ private void skipWaveImmediately(Player player) { } private EventData getActiveEventOrNull() { - if (!config.isEvent()) { + if (!isEvent()) { return null; } @@ -289,6 +289,14 @@ private EventData getActiveEventOrNull() { return event != null && event.isActive ? event : null; } + private boolean isFeatureDisabled(Feature feature) { + return config.runtime.disabledFeatures.contains(feature.key()); + } + + private boolean isEvent() { + return "event".equals(config.server.name); + } + private VoteDelta buildVoteDelta(Boolean previousVote, boolean like) { int reputationMagnitude = previousVote == null ? NEW_VOTE_REPUTATION_DELTA : CHANGED_VOTE_REPUTATION_DELTA; int reputationDelta = like ? reputationMagnitude : -reputationMagnitude; diff --git a/src/main/java/org/xcore/plugin/service/PlayerDisplayService.java b/src/main/java/org/xcore/plugin/service/PlayerDisplayService.java index 93876346..ca05e583 100644 --- a/src/main/java/org/xcore/plugin/service/PlayerDisplayService.java +++ b/src/main/java/org/xcore/plugin/service/PlayerDisplayService.java @@ -4,7 +4,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import mindustry.gen.Player; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.player.Badge; import org.xcore.plugin.session.Session; @@ -17,10 +17,10 @@ @Singleton public class PlayerDisplayService { - private final Config config; + private final TomlXcoreConfig config; @Inject - public PlayerDisplayService(Config config) { + public PlayerDisplayService(TomlXcoreConfig config) { this.config = config; } @@ -100,10 +100,14 @@ public String resolveSelectedBadgeTag(PlayerData data, Player player) { } public String resolveHexedTag(PlayerData data) { - if (data == null || !config.isMiniHexed()) return ""; + if (data == null || !isMiniHexedServer()) return ""; return data.hexedRank().tag == null ? "" : data.hexedRank().tag; } + private boolean isMiniHexedServer() { + return "mini-hexed".equals(config.server.name); + } + public String buildChatBadgePrefix(PlayerData data, Player player) { List parts = new ArrayList<>(); diff --git a/src/main/java/org/xcore/plugin/service/PlayerProfileSettingsService.java b/src/main/java/org/xcore/plugin/service/PlayerProfileSettingsService.java index 6f583a47..cf4d754c 100644 --- a/src/main/java/org/xcore/plugin/service/PlayerProfileSettingsService.java +++ b/src/main/java/org/xcore/plugin/service/PlayerProfileSettingsService.java @@ -3,7 +3,7 @@ import arc.util.Strings; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.player.Badge; @@ -27,14 +27,14 @@ public class PlayerProfileSettingsService { private final PlayerDataRepository playerDataRepository; private final PlayerDisplayService playerDisplayService; private final NetworkService network; - private final Config config; + private final TomlXcoreConfig config; @Inject public PlayerProfileSettingsService(SessionService sessionService, PlayerDataRepository playerDataRepository, PlayerDisplayService playerDisplayService, NetworkService network, - Config config) { + TomlXcoreConfig config) { this.sessionService = sessionService; this.playerDataRepository = playerDataRepository; this.playerDisplayService = playerDisplayService; @@ -77,7 +77,7 @@ public void updateCustomNickname(PlayerData targetData, String customNickname, b mutate(targetData, data -> data.customNickname = customNickname, data -> playerDataRepository.updateCustomNickname(data.uuid, customNickname), - data -> new PlayerCustomNicknameChangedCommandV1(data.uuid, data.customNickname, config.server), + data -> new PlayerCustomNicknameChangedCommandV1(data.uuid, data.customNickname, config.server.name), refreshDisplay, sync); } @@ -164,7 +164,7 @@ public void updateActiveBadge(PlayerData targetData, String badgeId, boolean ref mutate(targetData, data -> data.activeBadge = badgeId, data -> playerDataRepository.setActiveBadge(data.uuid, badgeId), - data -> new PlayerActiveBadgeChangedCommandV1(data.uuid, data.activeBadge, config.server), + data -> new PlayerActiveBadgeChangedCommandV1(data.uuid, data.activeBadge, config.server.name), refreshDisplay, sync); } @@ -173,7 +173,7 @@ public void updateBadgeSymbolColorMode(PlayerData targetData, String mode, boole mutate(targetData, data -> data.badgeSymbolColorMode = mode, data -> playerDataRepository.updateBadgeSymbolColorMode(data.uuid, mode), - data -> new PlayerBadgeSymbolColorModeChangedCommandV1(data.uuid, data.badgeSymbolColorMode, config.server), + data -> new PlayerBadgeSymbolColorModeChangedCommandV1(data.uuid, data.badgeSymbolColorMode, config.server.name), refreshDisplay, sync); } diff --git a/src/main/java/org/xcore/plugin/service/PrivateMessageService.java b/src/main/java/org/xcore/plugin/service/PrivateMessageService.java index cd951eff..9f9901ea 100644 --- a/src/main/java/org/xcore/plugin/service/PrivateMessageService.java +++ b/src/main/java/org/xcore/plugin/service/PrivateMessageService.java @@ -5,7 +5,7 @@ import arc.util.Strings; import org.bson.types.ObjectId; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.database.repository.PrivateMessageRepository; import org.xcore.plugin.model.PlayerData; @@ -27,7 +27,7 @@ public class PrivateMessageService { private final SessionService sessionService; private final SecurityService securityService; private final NetworkService networkService; - private final Config config; + private final TomlXcoreConfig config; private final GlobalConfig globalConfig; @Inject @@ -35,7 +35,7 @@ public PrivateMessageService(PrivateMessageRepository privateMessageRepository, SessionService sessionService, SecurityService securityService, NetworkService networkService, - Config config, + TomlXcoreConfig config, GlobalConfig globalConfig) { this.privateMessageRepository = privateMessageRepository; this.sessionService = sessionService; @@ -339,7 +339,7 @@ private void deliverOrDispatch(PrivateMessage privateMessage, int senderPid) { privateMessage.toUuid, privateMessage.toPid, privateMessage.message, - config.server + config.server.name )); } diff --git a/src/main/java/org/xcore/plugin/service/ServerDiscoveryService.java b/src/main/java/org/xcore/plugin/service/ServerDiscoveryService.java index 46fc15e6..a5f7d2d1 100644 --- a/src/main/java/org/xcore/plugin/service/ServerDiscoveryService.java +++ b/src/main/java/org/xcore/plugin/service/ServerDiscoveryService.java @@ -8,7 +8,7 @@ import mindustry.gen.Groups; import mindustry.net.Administration; import org.xcore.plugin.common.PluginState; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.nio.ByteBuffer; import java.time.Duration; @@ -19,19 +19,19 @@ @Singleton public class ServerDiscoveryService { - private final Config config; + private final TomlXcoreConfig config; private final PluginState pluginState; private String footer = ""; @Inject - public ServerDiscoveryService(Config config, PluginState pluginState) { + public ServerDiscoveryService(TomlXcoreConfig config, PluginState pluginState) { this.config = config; this.pluginState = pluginState; } public void updateFooter() { - this.footer = config.gameStartedTimer + this.footer = config.server.gameStartedTimer ? "\n[green]Game started [accent]" + Duration.ofMillis(Time.millis() - pluginState.gameStartTime).toMinutes() + "[] minutes ago." : ""; } @@ -52,7 +52,7 @@ public void handleDiscovery(ByteBuffer buffer) { writeString(buffer, Version.type); buffer.put((byte) state.rules.mode().ordinal()); - buffer.putInt(config.playerLimit > 0 ? config.getNoAdminPlayerLimit() : 0); + buffer.putInt(config.server.playerLimit > 0 ? noAdminPlayerLimit() : 0); writeString(buffer, description, 200); if (state.rules.modeName != null) { @@ -61,4 +61,8 @@ public void handleDiscovery(ByteBuffer buffer) { buffer.position(0); } + + private int noAdminPlayerLimit() { + return config.server.playerLimit + Groups.player.count(player -> player.admin); + } } diff --git a/src/main/java/org/xcore/plugin/service/TopMenuCacheService.java b/src/main/java/org/xcore/plugin/service/TopMenuCacheService.java index 1ef64755..b1a106cb 100644 --- a/src/main/java/org/xcore/plugin/service/TopMenuCacheService.java +++ b/src/main/java/org/xcore/plugin/service/TopMenuCacheService.java @@ -5,7 +5,7 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.model.LeaderboardCursor; import org.xcore.plugin.model.LeaderboardSlice; import org.xcore.plugin.model.PlayerData; @@ -23,10 +23,10 @@ public class TopMenuCacheService { private final RedisNetworkBackend backend; private final Gson redisGson; - private final Config config; + private final TomlXcoreConfig config; @Inject - public TopMenuCacheService(RedisNetworkBackend backend, @Named("redis") Gson redisGson, Config config) { + public TopMenuCacheService(RedisNetworkBackend backend, @Named("redis") Gson redisGson, TomlXcoreConfig config) { this.backend = backend; this.redisGson = redisGson; this.config = config; @@ -134,7 +134,7 @@ private String cursorKey(LeaderboardCursor cursor) { } private String keyPrefix() { - return "xcore:top:cache:" + config.server; + return "xcore:top:cache:" + config.server.name; } record CachedCount(long totalEntries, long createdAt) { diff --git a/src/main/java/org/xcore/plugin/service/TopMenuService.java b/src/main/java/org/xcore/plugin/service/TopMenuService.java index 8c304472..b9fccf9a 100644 --- a/src/main/java/org/xcore/plugin/service/TopMenuService.java +++ b/src/main/java/org/xcore/plugin/service/TopMenuService.java @@ -3,7 +3,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.xcore.plugin.common.CustomGatherers; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.LeaderboardCursor; import org.xcore.plugin.model.LeaderboardSlice; @@ -15,12 +15,12 @@ @Singleton public class TopMenuService { - private final Config config; + private final TomlXcoreConfig config; private final PlayerDataRepository playerDataRepository; private final TopMenuCacheService topMenuCacheService; @Inject - public TopMenuService(Config config, + public TopMenuService(TomlXcoreConfig config, PlayerDataRepository playerDataRepository, TopMenuCacheService topMenuCacheService) { this.config = config; @@ -29,17 +29,25 @@ public TopMenuService(Config config, } public TopCategory resolveDefaultCategory() { - if (config.isMiniPvP()) { + if (isMiniPvPServer()) { return TopCategory.MINI_PVP; } - if (config.isMiniHexed()) { + if (isMiniHexedServer()) { return TopCategory.HEXED; } return TopCategory.PLAYTIME; } + private boolean isMiniPvPServer() { + return "mini-pvp".equals(config.server.name); + } + + private boolean isMiniHexedServer() { + return "mini-hexed".equals(config.server.name); + } + public void invalidateLeaderboardCache() { topMenuCacheService.invalidateAll(); } diff --git a/src/main/java/org/xcore/plugin/service/TranslationCacheService.java b/src/main/java/org/xcore/plugin/service/TranslationCacheService.java index 44c4639c..50fb5a5d 100644 --- a/src/main/java/org/xcore/plugin/service/TranslationCacheService.java +++ b/src/main/java/org/xcore/plugin/service/TranslationCacheService.java @@ -5,7 +5,7 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.network.RedisNetworkBackend; import java.nio.charset.StandardCharsets; @@ -19,10 +19,10 @@ public class TranslationCacheService { private final RedisNetworkBackend backend; private final Gson redisGson; - private final Config config; + private final TomlXcoreConfig config; @Inject - public TranslationCacheService(RedisNetworkBackend backend, @Named("redis") Gson redisGson, Config config) { + public TranslationCacheService(RedisNetworkBackend backend, @Named("redis") Gson redisGson, TomlXcoreConfig config) { this.backend = backend; this.redisGson = redisGson; this.config = config; @@ -91,7 +91,7 @@ private String cacheKey(String sourceLanguage, String normalizedPipeline = normalize(pipelineSignature, "default"); String payload = normalizedSource + "|" + normalizedTarget + "|" + normalizedPipeline + "|" + inputText; - return "xcore:translation:cache:" + config.server + ":" + sha256(payload); + return "xcore:translation:cache:" + config.server.name + ":" + sha256(payload); } private String normalize(String value, String fallback) { diff --git a/src/main/java/org/xcore/plugin/service/TranslationMetricsService.java b/src/main/java/org/xcore/plugin/service/TranslationMetricsService.java index 64048aa2..49fb0926 100644 --- a/src/main/java/org/xcore/plugin/service/TranslationMetricsService.java +++ b/src/main/java/org/xcore/plugin/service/TranslationMetricsService.java @@ -2,7 +2,7 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.network.RedisNetworkBackend; import java.time.Instant; @@ -19,10 +19,10 @@ public class TranslationMetricsService { .withZone(ZoneOffset.UTC); private final RedisNetworkBackend backend; - private final Config config; + private final TomlXcoreConfig config; @Inject - public TranslationMetricsService(RedisNetworkBackend backend, Config config) { + public TranslationMetricsService(RedisNetworkBackend backend, TomlXcoreConfig config) { this.backend = backend; this.config = config; } @@ -139,19 +139,19 @@ private void incrementMinuteBucket(io.lettuce.core.api.sync.RedisCommands banById(int id, String adminName, String adminD target.pid, target.nickname, ip, - config.server, + config.server.name, commandOccurredAt(audit) )); } @@ -211,7 +211,7 @@ public ModerationResult muteById(int id, String adminName, String admi null ); - network.post(ModerationProtocolMapper.toMuteCreated(mute, config.server, eventOccurredAt(audit))); + network.post(ModerationProtocolMapper.toMuteCreated(mute, config.server.name, eventOccurredAt(audit))); postAuditEvent(audit); return ModerationResult.success("Player '" + target.nickname + "' muted successfully", mute); @@ -300,7 +300,7 @@ public ModerationResult tempBanByUuidOrIp(String uuid, String ip, Strin null, ban.name, ip, - config.server, + config.server.name, commandOccurredAt(audit) )); @@ -397,12 +397,12 @@ private AuditRecord appendAudit(AuditAction action, private void postAuditEvent(AuditRecord audit) { if (audit != null) { - network.post(ModerationProtocolMapper.toAuditAppended(audit, config.server)); + network.post(ModerationProtocolMapper.toAuditAppended(audit, config.server.name)); } } private void postBanEvents(BanData ban, AuditRecord audit) { - network.post(ModerationProtocolMapper.toBanCreated(ban, config.server, eventOccurredAt(audit))); + network.post(ModerationProtocolMapper.toBanCreated(ban, config.server.name, eventOccurredAt(audit))); } private ModerationPardonCommandV1 toPardonCommand(String uuid, Integer pid, String playerName, String ip, AuditRecord audit) { @@ -411,7 +411,7 @@ private ModerationPardonCommandV1 toPardonCommand(String uuid, Integer pid, Stri pid, playerName, ip, - config.server, + config.server.name, commandOccurredAt(audit) ); } diff --git a/src/main/java/org/xcore/plugin/service/network/RedisConnectionManager.java b/src/main/java/org/xcore/plugin/service/network/RedisConnectionManager.java index 32b53d14..a69c4b5f 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisConnectionManager.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisConnectionManager.java @@ -5,13 +5,13 @@ import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.net.URI; @Singleton public final class RedisConnectionManager { - private final Config config; + private final TomlXcoreConfig config; private final RedisTransportHealth transportHealth; private RedisClient client; @@ -19,7 +19,7 @@ public final class RedisConnectionManager { private RedisCommands commands; private boolean connectionWarningLogged; - public RedisConnectionManager(Config config, RedisTransportHealth transportHealth) { + public RedisConnectionManager(TomlXcoreConfig config, RedisTransportHealth transportHealth) { this.config = config; this.transportHealth = transportHealth; } @@ -31,12 +31,12 @@ public synchronized void connect() { transportHealth.markConnecting(); try { - client = RedisClient.create(config.redisUrl); + client = RedisClient.create(config.transport.redis.url); connection = client.connect(); commands = connection.sync(); connectionWarningLogged = false; transportHealth.markConnected(); - PLog.info("Redis connected: url=@", sanitizeRedisUrl(config.redisUrl)); + PLog.info("Redis connected: url=@", sanitizeRedisUrl(config.transport.redis.url)); } catch (RuntimeException e) { closeResources(); transportHealth.markUnavailable(); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisDiscordLinkCodeStore.java b/src/main/java/org/xcore/plugin/service/network/RedisDiscordLinkCodeStore.java index eb571310..43ed4ffc 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisDiscordLinkCodeStore.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisDiscordLinkCodeStore.java @@ -5,7 +5,7 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.util.Locale; @@ -16,10 +16,10 @@ public class RedisDiscordLinkCodeStore { private final RedisNetworkBackend backend; private final Gson redisGson; - private final Config config; + private final TomlXcoreConfig config; @Inject - public RedisDiscordLinkCodeStore(RedisNetworkBackend backend, @Named("redis") Gson redisGson, Config config) { + public RedisDiscordLinkCodeStore(RedisNetworkBackend backend, @Named("redis") Gson redisGson, TomlXcoreConfig config) { this.backend = backend; this.redisGson = redisGson; this.config = config; @@ -116,7 +116,7 @@ private String codeKey(String code) { } private String playerKey(String playerUuid) { - return "xcore:discord-link:player:" + config.server + ":" + playerUuid; + return "xcore:discord-link:player:" + config.server.name + ":" + playerUuid; } private String normalizeCode(String code) { diff --git a/src/main/java/org/xcore/plugin/service/network/RedisEnvelopeFactory.java b/src/main/java/org/xcore/plugin/service/network/RedisEnvelopeFactory.java index 27915fb2..1b4fd8aa 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisEnvelopeFactory.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisEnvelopeFactory.java @@ -2,7 +2,7 @@ import com.google.gson.Gson; import io.lettuce.core.StreamMessage; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -12,10 +12,10 @@ import java.util.UUID; final class RedisEnvelopeFactory { - private final Config config; + private final TomlXcoreConfig config; private final Gson gson; - RedisEnvelopeFactory(Config config, Gson gson) { + RedisEnvelopeFactory(TomlXcoreConfig config, Gson gson) { this.config = config; this.gson = gson; } @@ -29,16 +29,16 @@ Map eventFields(RedisStreamRouter.Route route, String payloadJso "idempotency_key", deterministicIdempotencyKey( route.messageType(), - config.server, + config.server.name, payloadJson, now, Math.max(60_000L, route.ttlMillis()) ) ); - fields.put("producer", "server:" + config.server); + fields.put("producer", "server:" + config.server.name); fields.put("created_at", String.valueOf(now)); fields.put("expires_at", String.valueOf(now + route.ttlMillis())); - fields.put("server", config.server); + fields.put("server", config.server.name); fields.put("payload_json", payloadJson); return fields; } @@ -58,15 +58,15 @@ Map rpcRequestFields(RedisStreamRouter.Route route, "idempotency_key", deterministicIdempotencyKey( "rpc." + route.messageType(), - config.server, + config.server.name, requestJson, now, Math.max(60_000L, timeoutMs) ) ); fields.put("reply_to", replyTo); - fields.put("requested_by", "server:" + config.server); - fields.put("server", config.server); + fields.put("requested_by", "server:" + config.server.name); + fields.put("server", config.server.name); fields.put("timeout_ms", String.valueOf(timeoutMs)); fields.put("created_at", String.valueOf(now)); fields.put("expires_at", String.valueOf(now + timeoutMs)); @@ -79,7 +79,7 @@ Map rpcResponseFields(RedisRpcTracker.RpcInboundContext context, fields.put("schema_version", "1"); fields.put("rpc_type", context.rpcType()); fields.put("correlation_id", context.correlationId()); - fields.put("server", config.server); + fields.put("server", config.server.name); fields.put("status", "ok"); fields.put("error_code", ""); fields.put("error_message", ""); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisIpReputationAllowlist.java b/src/main/java/org/xcore/plugin/service/network/RedisIpReputationAllowlist.java index e7016940..7af925f5 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisIpReputationAllowlist.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisIpReputationAllowlist.java @@ -4,7 +4,7 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.security.ingress.ipreputation.IpReputationAllowlist; import java.util.Collections; @@ -16,17 +16,17 @@ public class RedisIpReputationAllowlist implements IpReputationAllowlist { private static final String KEY_PREFIX = "xcore:ip-reputation:allowlist:v1"; private final RedisNetworkBackend backend; - private final Config config; + private final TomlXcoreConfig config; /** * Constructs a Redis-backed IP reputation allowlist scoped to the configured server. * * @param backend RedisNetworkBackend used to execute Redis commands for allowlist operations * @param redisGson Gson instance bound to "redis" (accepted for injection; not used directly) - * @param config Configuration whose {@code server} field is used to scope the Redis key + * @param config Configuration whose server-local name is used to scope the Redis key */ @Inject - public RedisIpReputationAllowlist(RedisNetworkBackend backend, @Named("redis") Gson redisGson, Config config) { + public RedisIpReputationAllowlist(RedisNetworkBackend backend, @Named("redis") Gson redisGson, TomlXcoreConfig config) { this.backend = backend; this.config = config; } @@ -109,7 +109,7 @@ public Set list() { * @return the namespaced Redis key for the allowlist (prefix + ":" + server name) */ private String allowlistKey() { - return KEY_PREFIX + ":" + config.server; + return KEY_PREFIX + ":" + config.server.name; } /** diff --git a/src/main/java/org/xcore/plugin/service/network/RedisIpReputationCache.java b/src/main/java/org/xcore/plugin/service/network/RedisIpReputationCache.java index e140f68e..63edd58f 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisIpReputationCache.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisIpReputationCache.java @@ -5,7 +5,7 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.security.ingress.ipreputation.IpReputationCache; import org.xcore.plugin.security.ingress.ipreputation.IpReputationResult; @@ -16,7 +16,7 @@ public class RedisIpReputationCache implements IpReputationCache { private final RedisNetworkBackend backend; private final Gson redisGson; - private final Config config; + private final TomlXcoreConfig config; /** * Constructs a Redis-backed IP reputation cache using the provided dependencies. @@ -26,7 +26,7 @@ public class RedisIpReputationCache implements IpReputationCache { * @param config configuration holding cache TTL and server scoping values */ @Inject - public RedisIpReputationCache(RedisNetworkBackend backend, @Named("redis") Gson redisGson, Config config) { + public RedisIpReputationCache(RedisNetworkBackend backend, @Named("redis") Gson redisGson, TomlXcoreConfig config) { this.backend = backend; this.redisGson = redisGson; this.config = config; @@ -87,7 +87,7 @@ public boolean put(String ip, IpReputationResult result) { * @return the Redis key in the form "xcore:ip-reputation:cache:v1:{server}:{normalizedIp}" */ private String cacheKey(String normalizedIp) { - return KEY_PREFIX + ":" + config.server + ":" + normalizedIp; + return KEY_PREFIX + ":" + config.server.name + ":" + normalizedIp; } /** diff --git a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java index 5ae9fd4b..c67c0b9e 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisNetworkBackend.java @@ -14,7 +14,7 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.protocol.generated.runtime.ProtocolPayload; import java.time.Instant; @@ -40,7 +40,7 @@ public abstract static class Subscription { public abstract static class RequestSubscription { public abstract void cancel(); } - private final Config config; + private final TomlXcoreConfig config; private final Gson gson; private final RedisStreamRouter router; private final RedisEnvelopeFactory envelopeFactory; @@ -63,7 +63,7 @@ public abstract static class RequestSubscription { private final AtomicLong reclaimedMessages = new AtomicLong(); @Inject - public RedisNetworkBackend(Config config, + public RedisNetworkBackend(TomlXcoreConfig config, @Named("redis") Gson gson, RedisStreamRouter router, RedisStreamSupport streamSupport, @@ -80,11 +80,11 @@ public RedisNetworkBackend(Config config, this.envelopeFactory = new RedisEnvelopeFactory(config, gson); } - public RedisNetworkBackend(Config config) { + public RedisNetworkBackend(TomlXcoreConfig config) { this(config, createRedisGson(), new RedisStreamRouter(), createStandaloneDependencies(config)); } - private RedisNetworkBackend(Config config, + private RedisNetworkBackend(TomlXcoreConfig config, Gson gson, RedisStreamRouter router, StandaloneDependencies dependencies) { @@ -97,7 +97,7 @@ private RedisNetworkBackend(Config config, dependencies.transportHealth()); } - private static StandaloneDependencies createStandaloneDependencies(Config config) { + private static StandaloneDependencies createStandaloneDependencies(TomlXcoreConfig config) { RedisTransportHealth transportHealth = new RedisTransportHealth(); return new StandaloneDependencies(new RedisConnectionManager(config, transportHealth), transportHealth); } @@ -143,7 +143,7 @@ public void send(Object event) { } try { - var route = router.route(event, config.server); + var route = router.route(event, config.server.name); long now = System.currentTimeMillis(); String payloadJson = payloadJson(event); RedisCommands commands = connectionManager.commands(); @@ -167,7 +167,7 @@ public Subscription subscribe(Class type, Cons listener) { throw new IllegalStateException("Redis backend is unavailable for subscribe"); } - List streams = router.subscribeStreamsFor(type, config.server); + List streams = router.subscribeStreamsFor(type, config.server.name); if (streams.isEmpty()) { throw new UnsupportedOperationException("Redis subscribe has no stream mapping for type: " + type.getName()); } @@ -180,7 +180,7 @@ public Subscription subscribe(Class type, Cons listener) { .start(() -> consumeLoop(stream, type, listener)); registerSubscriberThread(subscription, thread); - if (config.redisReclaimEnabled) { + if (config.transport.redis.reclaim.enabled) { String group = groupFor(type, stream); Thread reclaimThread = Thread.ofVirtual() .name("redis-reclaim-" + type.getSimpleName() + "-" + stream) @@ -209,8 +209,8 @@ public RequestSubscription request(REQ request, Cons listen return requestHandle; } - var route = router.route(request, config.server); - String replyTo = "xcore:rpc:resp:" + config.server; + var route = router.route(request, config.server.name); + String replyTo = "xcore:rpc:resp:" + config.server.name; String correlationId = UUID.randomUUID().toString(); long now = System.currentTimeMillis(); long timeoutMs = 5000L; @@ -320,7 +320,7 @@ private void consumeLoop(String stream, Class type, Cons listener) { RedisCommands subCommands = subConnection.sync(); String group = groupFor(type, stream); ensureGroup(subCommands, stream, group); - Consumer consumer = Consumer.from(group, config.redisConsumerName); + Consumer consumer = Consumer.from(group, config.transport.redis.consumerName); while (!Thread.currentThread().isInterrupted()) { List> messages; @@ -375,15 +375,15 @@ private void reclaimLoop(String stream, String group, Class type, Cons RedisCommands reclaimCommands = reclaimConnection.sync(); ensureGroup(reclaimCommands, stream, group); String cursor = "0-0"; - Consumer consumer = Consumer.from(group, config.redisConsumerName); + Consumer consumer = Consumer.from(group, config.transport.redis.consumerName); while (!Thread.currentThread().isInterrupted()) { try { var claimed = reclaimCommands.xautoclaim(stream, new XAutoClaimArgs() .consumer(consumer) - .minIdleTime(config.redisReclaimMinIdleMs) + .minIdleTime(config.transport.redis.reclaim.minIdleMs) .startId(cursor) - .count(config.redisReclaimBatch)); + .count(config.transport.redis.reclaim.batch)); if (claimed == null) { Thread.sleep(1000L); @@ -460,7 +460,7 @@ private boolean dispatchStreamMessage(RedisCommands consumer String idempotencyKey = message.getBody().getOrDefault("idempotency_key", ""); if (!idempotencyKey.isBlank()) { long ttlSeconds = envelopeFactory.resolveIdempotencyTtlSeconds(expiresAt); - idempotencyRedisKey = "xcore:idmp:consume:" + config.server + ":" + idempotencyKey; + idempotencyRedisKey = "xcore:idmp:consume:" + config.server.name + ":" + idempotencyKey; String claimed = consumerCommands.set( idempotencyRedisKey, "1", @@ -490,7 +490,7 @@ private boolean dispatchStreamMessage(RedisCommands consumer T event = decodeEvent(payloadJson, type); if (router.isRpcRequestType(type)) { String correlationId = message.getBody().getOrDefault("correlation_id", ""); - String replyTo = message.getBody().getOrDefault("reply_to", "xcore:rpc:resp:" + config.server); + String replyTo = message.getBody().getOrDefault("reply_to", "xcore:rpc:resp:" + config.server.name); String rpcType = message.getBody().getOrDefault("rpc_type", "rpc.unknown"); rpcTracker.registerInbound(event, correlationId, replyTo, rpcType, System.currentTimeMillis()); } @@ -553,10 +553,10 @@ private void handleFailedMessage(RedisCommands commands, String reason) { String key = failureKey(stream, message.getId()); int attempt = deliveryFailures.merge(key, 1, Integer::sum); - int maxAttempts = Math.max(1, config.redisMaxDeliveryAttempts); + int maxAttempts = Math.max(1, config.transport.redis.dlq.maxDeliveryAttempts); if (attempt >= maxAttempts) { - if (config.redisDlqEnabled) { + if (config.transport.redis.dlq.enabled) { routeToDlq(commands, stream, group, message, attempt, reason); } commands.xack(stream, group, message.getId()); diff --git a/src/main/java/org/xcore/plugin/service/network/RedisObserverStateStore.java b/src/main/java/org/xcore/plugin/service/network/RedisObserverStateStore.java index 5facf97a..9fbdf3da 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisObserverStateStore.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisObserverStateStore.java @@ -6,7 +6,7 @@ import jakarta.inject.Named; import jakarta.inject.Singleton; import mindustry.game.Team; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; @Singleton public class RedisObserverStateStore { @@ -15,10 +15,10 @@ public class RedisObserverStateStore { private final RedisNetworkBackend backend; private final Gson redisGson; - private final Config config; + private final TomlXcoreConfig config; @Inject - public RedisObserverStateStore(RedisNetworkBackend backend, @Named("redis") Gson redisGson, Config config) { + public RedisObserverStateStore(RedisNetworkBackend backend, @Named("redis") Gson redisGson, TomlXcoreConfig config) { this.backend = backend; this.redisGson = redisGson; this.config = config; @@ -73,7 +73,7 @@ private int resolveReturnTeamId(Team returnTeam) { } private String key(String playerUuid) { - return "xcore:observer:" + config.server + ":" + playerUuid; + return "xcore:observer:" + config.server.name + ":" + playerUuid; } public record CachedObserverState(int returnTeamId, long createdAt) { diff --git a/src/main/java/org/xcore/plugin/service/network/RedisStreamSupport.java b/src/main/java/org/xcore/plugin/service/network/RedisStreamSupport.java index 7805b093..718b7d38 100644 --- a/src/main/java/org/xcore/plugin/service/network/RedisStreamSupport.java +++ b/src/main/java/org/xcore/plugin/service/network/RedisStreamSupport.java @@ -5,7 +5,7 @@ import io.lettuce.core.XReadArgs; import io.lettuce.core.api.sync.RedisCommands; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.util.Map; @@ -17,16 +17,16 @@ final class RedisStreamSupport { private static final long MAXLEN_RPC_RESP = 20_000L; private static final long MAXLEN_DLQ = 100_000L; - private final Config config; + private final TomlXcoreConfig config; - RedisStreamSupport(Config config) { + RedisStreamSupport(TomlXcoreConfig config) { this.config = config; } String groupFor(Class type, String stream) { - return config.redisGroupPrefix + return config.transport.redis.groupPrefix + ":" - + config.server + + config.server.name + ":" + type.getSimpleName().toLowerCase() + ":" @@ -65,7 +65,7 @@ long streamMaxLen(String stream) { if (stream.startsWith("xcore:rpc:resp:")) { return MAXLEN_RPC_RESP; } - if (stream.startsWith(config.redisDlqPrefix + ":")) { + if (stream.startsWith(config.transport.redis.dlq.prefix + ":")) { return MAXLEN_DLQ; } return MAXLEN_EVT; @@ -73,12 +73,12 @@ long streamMaxLen(String stream) { String dlqStreamFor(String sourceStream) { if (sourceStream.startsWith("xcore:rpc:")) { - return config.redisDlqPrefix + ":rpc"; + return config.transport.redis.dlq.prefix + ":rpc"; } if (sourceStream.startsWith("xcore:cmd:")) { - return config.redisDlqPrefix + ":cmd"; + return config.transport.redis.dlq.prefix + ":cmd"; } - return config.redisDlqPrefix + ":evt"; + return config.transport.redis.dlq.prefix + ":evt"; } String failureKey(String stream, String messageId) { diff --git a/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryMenu.java b/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryMenu.java index 772597ee..daef7722 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryMenu.java @@ -3,7 +3,6 @@ import io.avaje.inject.PostConstruct; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.AuditActorType; @@ -34,12 +33,11 @@ public class AuditHistoryMenu extends Menu { private final MenuService menuService; @Inject - public AuditHistoryMenu(Config config, - GlobalConfig globalConfig, + public AuditHistoryMenu(GlobalConfig globalConfig, SessionService sessionService, AuditService auditService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.auditService = auditService; this.menuService = menuService; } diff --git a/src/main/java/org/xcore/plugin/ui/menu/BanMenu.java b/src/main/java/org/xcore/plugin/ui/menu/BanMenu.java index 34cb9146..0ee4a871 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/BanMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/BanMenu.java @@ -4,7 +4,6 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import mindustry.gen.Player; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.model.BanData; import org.xcore.plugin.service.TimeService; @@ -38,13 +37,12 @@ public class BanMenu extends Menu { private final MenuService menuService; @Inject - public BanMenu(Config config, - GlobalConfig globalConfig, + public BanMenu(GlobalConfig globalConfig, SessionService sessionService, ModerationService moderationService, TimeService timeService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.moderationService = moderationService; this.timeService = timeService; this.menuService = menuService; diff --git a/src/main/java/org/xcore/plugin/ui/menu/DiscordMenu.java b/src/main/java/org/xcore/plugin/ui/menu/DiscordMenu.java index 6c0eb724..16bb75a5 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/DiscordMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/DiscordMenu.java @@ -3,7 +3,6 @@ import io.avaje.inject.PostConstruct; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.service.DiscordLinkService; import org.xcore.plugin.session.Session; @@ -20,12 +19,11 @@ public class DiscordMenu extends Menu { private final MenuService menuService; @Inject - public DiscordMenu(Config config, - GlobalConfig globalConfig, + public DiscordMenu(GlobalConfig globalConfig, SessionService sessionService, DiscordLinkService discordLinkService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.discordLinkService = discordLinkService; this.menuService = menuService; } diff --git a/src/main/java/org/xcore/plugin/ui/menu/EventMenu.java b/src/main/java/org/xcore/plugin/ui/menu/EventMenu.java index 11cf8178..3f810780 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/EventMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/EventMenu.java @@ -4,7 +4,6 @@ import jakarta.inject.Inject; import jakarta.inject.Provider; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.model.EventData; import org.xcore.plugin.model.MapData; @@ -30,11 +29,11 @@ public class EventMenu extends Menu { private final MenuService menuService; @Inject - public EventMenu(Config config, GlobalConfig globalConfig, SessionService sessionService, + public EventMenu(GlobalConfig globalConfig, SessionService sessionService, MapService mapService, EventService eventService, EventEditorService eventEditorService, EventViewService eventViewService, VoteService voteService, Provider mapMenu, MenuService menuService) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.mapService = mapService; this.eventService = eventService; this.eventEditorService = eventEditorService; diff --git a/src/main/java/org/xcore/plugin/ui/menu/HelpMenu.java b/src/main/java/org/xcore/plugin/ui/menu/HelpMenu.java index 81a35e97..4af48789 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/HelpMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/HelpMenu.java @@ -12,7 +12,6 @@ import org.xcore.cloud.mindustry.MindustryCloudCommand; import org.xcore.plugin.cloud.CloudService; import org.xcore.plugin.cloud.XCoreSender; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; @@ -38,8 +37,8 @@ public class HelpMenu extends Menu { private final MenuService menuService; @Inject - public HelpMenu(Config config, GlobalConfig globalConfig, SessionService sessionService, Provider cloud, MenuService menuService) { - super(config, globalConfig, sessionService); + public HelpMenu(GlobalConfig globalConfig, SessionService sessionService, Provider cloud, MenuService menuService) { + super(globalConfig, sessionService); this.cloud = cloud; this.menuService = menuService; } diff --git a/src/main/java/org/xcore/plugin/ui/menu/InformationMenu.java b/src/main/java/org/xcore/plugin/ui/menu/InformationMenu.java index 5360cc00..b3307785 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/InformationMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/InformationMenu.java @@ -5,8 +5,8 @@ import jakarta.inject.Provider; import jakarta.inject.Singleton; import org.xcore.plugin.common.BuildInfo; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.ui.MenuService; @@ -27,6 +27,7 @@ public class InformationMenu extends Menu { private final BuildInfo buildInfo; private final MenuService menuService; + private final TomlXcoreConfig config; private final Provider map; private final Provider event; @@ -34,10 +35,11 @@ public class InformationMenu extends Menu { private final Provider player; @Inject - public InformationMenu(Config config, GlobalConfig globalConfig, SessionService sessionService, + public InformationMenu(TomlXcoreConfig config, GlobalConfig globalConfig, SessionService sessionService, BuildInfo buildInfo, MenuService menuService, Provider map, Provider event, Provider help, Provider player) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); + this.config = config; this.buildInfo = buildInfo; this.menuService = menuService; this.map = map; @@ -91,7 +93,7 @@ public MenuScreen render(MenuRenderContext context) { MenuButton.of(local.t("map-maps"), "maps"), MenuButton.of(local.t("player-menu-players"), "players") ); - if (config.isEvent()) { + if ("event".equals(config.server.name)) { grid.row( MenuButton.of(local.t("event-menu-main"), "event-main"), MenuButton.of(local.t("event-events"), "event-list") @@ -139,7 +141,7 @@ public MenuScreen render(MenuRenderContext context) { .row(MenuButton.of(local.t("discord-red-vs-blue"), "discord-red-vs-blue")) .defaultNavigation(context.session(), local); return MenuScreen.followUp( - local.t("commands-info-title", args("server-name", config.server)), + local.t("commands-info-title", args("server-name", config.server.name)), local.t("commands-info-text", args("version", buildInfo.getVersion())), grid.build() ); diff --git a/src/main/java/org/xcore/plugin/ui/menu/MapFlows.java b/src/main/java/org/xcore/plugin/ui/menu/MapFlows.java index 6a24ec1f..32063b3e 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/MapFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/MapFlows.java @@ -4,7 +4,7 @@ import mindustry.maps.Map; import org.xcore.plugin.common.CustomGatherers; import org.xcore.plugin.common.SeqStream; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.EventData; @@ -154,11 +154,11 @@ private String mapIdentity(Map map) { static final class MapFlow extends BaseMenuFlow { private final MapMenu menu; - private final Config config; + private final TomlXcoreConfig config; private final EventDataRepository eventDataRepository; private final MapService mapService; - MapFlow(MapMenu menu, Config config, EventDataRepository eventDataRepository, MapService mapService) { + MapFlow(MapMenu menu, TomlXcoreConfig config, EventDataRepository eventDataRepository, MapService mapService) { super(ROUTE_MAP, MapState.class); this.menu = menu; this.config = config; @@ -202,6 +202,14 @@ static final class MapFlow extends BaseMenuFlow { }); } + private boolean isFeatureDisabled(Feature feature) { + return config.runtime.disabledFeatures.contains(feature.key()); + } + + private boolean isEvent() { + return "event".equals(config.server.name); + } + @Override public MapState createState(Session session, MenuRoute route, MapState currentState) { MapState state = currentState == null ? new MapState() : currentState; @@ -239,8 +247,8 @@ public MenuScreen render(MenuRenderContext context) { ); EventData activeEvent = eventDataRepository.findActive().orElse(null); - boolean rtvEnabled = !config.isFeatureDisabled(Feature.RTV); - if (rtvEnabled && (!config.isEvent() || (activeEvent == null || !activeEvent.isActive) || activeEvent.map.equals(mapData.id))) { + boolean rtvEnabled = !isFeatureDisabled(Feature.RTV); + if (rtvEnabled && (!isEvent() || (activeEvent == null || !activeEvent.isActive) || activeEvent.map.equals(mapData.id))) { List rtvRow = new ArrayList<>(); rtvRow.add(MenuButton.of(session.locale().t("map-rtv"), "rtv")); if (session.player.admin) { @@ -251,7 +259,7 @@ public MenuScreen render(MenuRenderContext context) { grid.row(MenuButton.of(session.locale().t("map-maps-back"), "maps")); - if (config.isEvent()) { + if (isEvent()) { grid.row(MenuButton.of(session.locale().t("event-menu-create-start-map"), "create-start")); } diff --git a/src/main/java/org/xcore/plugin/ui/menu/MapMenu.java b/src/main/java/org/xcore/plugin/ui/menu/MapMenu.java index fb105b9e..e73b149b 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/MapMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/MapMenu.java @@ -6,8 +6,8 @@ import jakarta.inject.Singleton; import mindustry.game.Team; import mindustry.gen.Groups; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.MapData; @@ -34,14 +34,14 @@ public class MapMenu extends Menu { private final MapService mapService; final Provider eventMenu; private final MenuService menuService; - private final Config config; + private final TomlXcoreConfig config; private final EventDataRepository eventDataRepository; @Inject - public MapMenu(Config config, GlobalConfig globalConfig, SessionService sessionService, + public MapMenu(TomlXcoreConfig config, GlobalConfig globalConfig, SessionService sessionService, MapDataRepository mapDataRepository, EventDataRepository eventDataRepository, MapService mapService, Provider eventMenu, MenuService menuService) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.config = config; this.mapDataRepository = mapDataRepository; this.eventDataRepository = eventDataRepository; diff --git a/src/main/java/org/xcore/plugin/ui/menu/Menu.java b/src/main/java/org/xcore/plugin/ui/menu/Menu.java index c79702c5..9c6f2314 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/Menu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/Menu.java @@ -3,7 +3,6 @@ import mindustry.gen.Player; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.cloud.XCoreSender; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.Session; @@ -12,12 +11,10 @@ import static com.ospx.flubundle.Bundle.args; public class Menu { - protected final Config config; protected final GlobalConfig globalConfig; protected final SessionService sessionService; - public Menu(Config config, GlobalConfig globalConfig, SessionService sessionService) { - this.config = config; + public Menu(GlobalConfig globalConfig, SessionService sessionService) { this.globalConfig = globalConfig; this.sessionService = sessionService; } diff --git a/src/main/java/org/xcore/plugin/ui/menu/MessageMenu.java b/src/main/java/org/xcore/plugin/ui/menu/MessageMenu.java index aed57302..aa997136 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/MessageMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/MessageMenu.java @@ -4,7 +4,6 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.bson.types.ObjectId; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.service.PrivateMessageService; import org.xcore.plugin.session.Session; @@ -19,12 +18,11 @@ public class MessageMenu extends Menu { private final MenuService menuService; @Inject - public MessageMenu(Config config, - GlobalConfig globalConfig, + public MessageMenu(GlobalConfig globalConfig, SessionService sessionService, PrivateMessageService privateMessageService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.privateMessageService = privateMessageService; this.menuService = menuService; } diff --git a/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java b/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java index a1230267..e15a0c36 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java @@ -4,7 +4,6 @@ import io.avaje.inject.PostConstruct; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.database.repository.GameDataRepository; import org.xcore.plugin.model.PlayerData; @@ -23,8 +22,7 @@ public class PlayerMenu extends Menu { private final MenuService menuService; @Inject - public PlayerMenu(Config config, - GlobalConfig globalConfig, + public PlayerMenu(GlobalConfig globalConfig, SessionService sessionService, GameDataRepository gameDataRepository, Bundle bundle, @@ -32,7 +30,7 @@ public PlayerMenu(Config config, PlayerProfileSettingsService profileSettings, AuditHistoryMenu auditHistoryMenu, MenuService menuService) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.bundle = bundle; this.profileSettings = profileSettings; this.menuService = menuService; diff --git a/src/main/java/org/xcore/plugin/ui/menu/TopMenu.java b/src/main/java/org/xcore/plugin/ui/menu/TopMenu.java index f42071f5..342494f2 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/TopMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/TopMenu.java @@ -3,7 +3,6 @@ import io.avaje.inject.PostConstruct; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.LeaderboardCursor; @@ -45,13 +44,12 @@ public class TopMenu extends Menu { private final MenuService menuService; @Inject - public TopMenu(Config config, - GlobalConfig globalConfig, + public TopMenu(GlobalConfig globalConfig, SessionService sessionService, MenuService menuService, TopMenuService topMenuService, PlayerMenu playerMenu) { - super(config, globalConfig, sessionService); + super(globalConfig, sessionService); this.menuService = menuService; this.topMenuService = topMenuService; this.playerMenu = playerMenu; diff --git a/src/main/java/org/xcore/plugin/vote/VoteKick.java b/src/main/java/org/xcore/plugin/vote/VoteKick.java index 46b2289a..4d22de3d 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteKick.java +++ b/src/main/java/org/xcore/plugin/vote/VoteKick.java @@ -12,7 +12,7 @@ import mindustry.net.Packets; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerActionV1; import org.xcore.plugin.common.VersionComparator; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.config.GlobalConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; @@ -42,7 +42,7 @@ public class VoteKick extends VoteSession { private final SessionService sessionService; private final NetworkService network; private final VoteService voteService; - private final Config config; + private final TomlXcoreConfig config; private final GlobalConfig globalConfig; @Inject @@ -55,7 +55,7 @@ public VoteKick( SessionService sessionService, NetworkService network, VoteService voteService, - Config config, + TomlXcoreConfig config, GlobalConfig globalConfig) { super(globalConfig); this.starter = starter; @@ -111,7 +111,7 @@ public void vote(Player player, int sign) { } if (network != null) { - network.post(new ServerActionV1(stripColors(message), config.server)); + network.post(new ServerActionV1(stripColors(message), config.server.name)); } } @@ -147,7 +147,7 @@ private ModerationVoteKickCreatedV1 buildVoteKickEvent() { reason, List.copyOf(votesFor), List.copyOf(votesAgainst), - config.server, + config.server.name, Instant.now() ); } @@ -211,7 +211,7 @@ public void success() { if (network != null) { network.post(buildVoteKickEvent()); network.post(new ServerActionV1( - systemLocal.format("votekick-success", bundleArgs), config.server)); + systemLocal.format("votekick-success", bundleArgs), config.server.name)); } onKick.get(target); } diff --git a/src/test/java/org/xcore/plugin/cloud/CloudCommandPipelineIntegrationTest.java b/src/test/java/org/xcore/plugin/cloud/CloudCommandPipelineIntegrationTest.java index ae66b510..a8c69dff 100644 --- a/src/test/java/org/xcore/plugin/cloud/CloudCommandPipelineIntegrationTest.java +++ b/src/test/java/org/xcore/plugin/cloud/CloudCommandPipelineIntegrationTest.java @@ -14,8 +14,8 @@ import org.xcore.cloud.mindustry.MindustrySender; import org.xcore.plugin.cloud.config.CloudGuardConfigurer; import org.xcore.plugin.cloud.config.DisabledCommandPolicy; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.SecurityService; import org.xcore.plugin.session.SessionService; @@ -39,8 +39,8 @@ void setUp() { var sessionService = mock(SessionService.class); var bundle = mock(Bundle.class); var globalConfig = new GlobalConfig(); - var config = new Config(); - config.disabledCommands = Set.of("test foo", "root"); + var config = new TomlXcoreConfig(); + config.runtime.disabledCommands = Set.of("test foo", "root"); var player = mock(mindustry.gen.Player.class); when(player.uuid()).thenReturn("test-uuid"); diff --git a/src/test/java/org/xcore/plugin/cloud/config/DisabledCommandPolicyTest.java b/src/test/java/org/xcore/plugin/cloud/config/DisabledCommandPolicyTest.java index 92e522e0..ef25d2b0 100644 --- a/src/test/java/org/xcore/plugin/cloud/config/DisabledCommandPolicyTest.java +++ b/src/test/java/org/xcore/plugin/cloud/config/DisabledCommandPolicyTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import java.util.Set; @@ -13,7 +13,7 @@ class DisabledCommandPolicyTest { @Test @DisplayName("normalizes command names consistently") void normalizeCommandName_normalizesSlashesCaseAndWhitespace() { - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); DisabledCommandPolicy policy = new DisabledCommandPolicy(config); assertThat(policy.normalizeCommandName(" /TeSt Foo ")).isEqualTo("test foo"); @@ -24,8 +24,8 @@ void normalizeCommandName_normalizesSlashesCaseAndWhitespace() { @Test @DisplayName("string disable check requires exact explicit match") void isCommandDisabled_string_usesExactMatch() { - Config config = new Config(); - config.disabledCommands = Set.of("map stats"); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.runtime.disabledCommands = Set.of("map stats"); DisabledCommandPolicy policy = new DisabledCommandPolicy(config); assertThat(policy.isCommandDisabled("map stats")).isTrue(); @@ -37,8 +37,8 @@ void isCommandDisabled_string_usesExactMatch() { @Test @DisplayName("disabledCommandKey matches full command prefixes but not unrelated names") void disabledCommandKey_matchesPrefixSemantics() { - Config config = new Config(); - config.disabledCommands = Set.of("map", "map stats"); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.runtime.disabledCommands = Set.of("map", "map stats"); DisabledCommandPolicy policy = new DisabledCommandPolicy(config); assertThat(policy.disabledCommandKey("map next")).isEqualTo("map"); diff --git a/src/test/java/org/xcore/plugin/command/CloudCommandRegistrarTest.java b/src/test/java/org/xcore/plugin/command/CloudCommandRegistrarTest.java new file mode 100644 index 00000000..b8ffafc3 --- /dev/null +++ b/src/test/java/org/xcore/plugin/command/CloudCommandRegistrarTest.java @@ -0,0 +1,121 @@ +package org.xcore.plugin.command; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.xcore.plugin.cloud.CloudService; +import org.xcore.plugin.command.controller.CloudClientController; +import org.xcore.plugin.command.controller.CloudServerController; +import org.xcore.plugin.command.controller.client.EventController; +import org.xcore.plugin.command.controller.client.HexedController; +import org.xcore.plugin.config.TomlXcoreConfig; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class CloudCommandRegistrarTest { + + @Test + @DisplayName("init registers hexed controller only on mini-hexed server") + void init_registersHexedControllerOnlyOnMiniHexedServer() { + // Arrange + CloudService cloudService = mock(CloudService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-hexed"; + HexedController hexedController = mock(HexedController.class); + CloudClientController genericController = mock(CloudClientController.class); + CloudServerController serverController = mock(CloudServerController.class); + + CloudCommandRegistrar registrar = new CloudCommandRegistrar( + cloudService, + config, + List.of(hexedController, genericController), + List.of(serverController) + ); + + // Act + registrar.init(); + + // Assert + verify(cloudService).registerClient(hexedController); + verify(cloudService).registerClient(genericController); + verify(cloudService).registerServer(serverController); + } + + @Test + @DisplayName("init skips hexed controller outside mini-hexed server") + void init_skipsHexedControllerOutsideMiniHexedServer() { + // Arrange + CloudService cloudService = mock(CloudService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + HexedController hexedController = mock(HexedController.class); + CloudClientController genericController = mock(CloudClientController.class); + + CloudCommandRegistrar registrar = new CloudCommandRegistrar( + cloudService, + config, + List.of(hexedController, genericController), + List.of() + ); + + // Act + registrar.init(); + + // Assert + verify(cloudService, never()).registerClient(hexedController); + verify(cloudService).registerClient(genericController); + } + + @Test + @DisplayName("init registers event controller only on event server") + void init_registersEventControllerOnlyOnEventServer() { + // Arrange + CloudService cloudService = mock(CloudService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "event"; + EventController eventController = mock(EventController.class); + CloudClientController genericController = mock(CloudClientController.class); + + CloudCommandRegistrar registrar = new CloudCommandRegistrar( + cloudService, + config, + List.of(eventController, genericController), + List.of() + ); + + // Act + registrar.init(); + + // Assert + verify(cloudService).registerClient(eventController); + verify(cloudService).registerClient(genericController); + } + + @Test + @DisplayName("init skips event controller outside event server") + void init_skipsEventControllerOutsideEventServer() { + // Arrange + CloudService cloudService = mock(CloudService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + EventController eventController = mock(EventController.class); + CloudClientController genericController = mock(CloudClientController.class); + + CloudCommandRegistrar registrar = new CloudCommandRegistrar( + cloudService, + config, + List.of(eventController, genericController), + List.of() + ); + + // Act + registrar.init(); + + // Assert + verify(cloudService, never()).registerClient(eventController); + verify(cloudService).registerClient(genericController); + } +} diff --git a/src/test/java/org/xcore/plugin/command/controller/client/BadgeControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/client/BadgeControllerTest.java new file mode 100644 index 00000000..e5dae25e --- /dev/null +++ b/src/test/java/org/xcore/plugin/command/controller/client/BadgeControllerTest.java @@ -0,0 +1,98 @@ +package org.xcore.plugin.command.controller.client; + +import com.ospx.flubundle.Bundle; +import com.ospx.flubundle.BundleContext; +import com.ospx.flubundle.Localizer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.xcore.plugin.cloud.XCoreSender; +import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; +import org.xcore.plugin.database.repository.PlayerDataRepository; +import org.xcore.plugin.model.PlayerData; +import org.xcore.plugin.service.NetworkService; +import org.xcore.plugin.service.PlayerDisplayService; +import org.xcore.plugin.session.Session; +import org.xcore.plugin.session.SessionService; +import org.xcore.plugin.ui.MenuService; +import org.xcore.plugin.ui.menu.PlayerMenu; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerActiveBadgeChangedCommandV1; + +import java.util.Locale; +import java.util.Map; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BadgeControllerTest { + + @Test + @DisplayName("badge clear publishes active badge change with structured server name") + void clear_publishesActiveBadgeChangeWithStructuredServerName() { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + Session session = session("uuid-1", "translator"); + SessionService sessionService = mock(SessionService.class); + when(sessionService.get("uuid-1")).thenReturn(session); + doAnswer(invocation -> { + Session target = invocation.getArgument(0); + target.data.activeBadge = invocation.getArgument(1); + return true; + }).when(sessionService).setActiveBadge(any(Session.class), anyString()); + + PlayerMenu playerMenu = mock(PlayerMenu.class); + PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); + NetworkService network = mock(NetworkService.class); + BadgeController controller = new BadgeController(config, sessionService, playerMenu, playerDisplayService, network); + + XCoreSender sender = mock(XCoreSender.class); + when(sender.player()).thenReturn(session.player); + + controller.clear(sender); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(PlayerActiveBadgeChangedCommandV1.class); + verify(network).post(eventCaptor.capture()); + verify(playerDisplayService).refresh(session); + assertThat(eventCaptor.getValue().playerUuid()).isEqualTo("uuid-1"); + assertThat(eventCaptor.getValue().activeBadge()).isEmpty(); + assertThat(eventCaptor.getValue().server()).isEqualTo("mini-pvp"); + } + + private Session session(String uuid, String activeBadge) { + mindustry.gen.Player player = player(uuid); + Bundle bundle = mock(Bundle.class); + Localizer localizer = mock(Localizer.class); + BundleContext context = mock(BundleContext.class); + when(bundle.localizer(any(Supplier.class))).thenReturn(localizer); + when(bundle.context(any(mindustry.gen.Player.class), any(Supplier.class))).thenReturn(context); + when(localizer.locale()).thenReturn(Locale.ENGLISH); + when(localizer.format(anyString(), anyMap())).thenAnswer(invocation -> invocation.getArgument(0)); + + PlayerData data = new PlayerData(); + data.uuid = uuid; + data.activeBadge = activeBadge; + + return new Session( + new GlobalConfig(), + bundle, + mock(MenuService.class), + mock(PlayerDataRepository.class), + player, + data + ); + } + + private mindustry.gen.Player player(String uuid) { + mindustry.gen.Player player = mock(mindustry.gen.Player.class); + when(player.uuid()).thenReturn(uuid); + return player; + } +} diff --git a/src/test/java/org/xcore/plugin/command/controller/client/HexedControllerObserverTest.java b/src/test/java/org/xcore/plugin/command/controller/client/HexedControllerObserverTest.java index 312a9164..c57ac194 100644 --- a/src/test/java/org/xcore/plugin/command/controller/client/HexedControllerObserverTest.java +++ b/src/test/java/org/xcore/plugin/command/controller/client/HexedControllerObserverTest.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.plugin.cloud.XCoreSender; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.gamemode.hexed.HexMember; import org.xcore.plugin.gamemode.hexed.MiniHexedService; import org.xcore.plugin.localization.Localization; @@ -34,7 +34,7 @@ void ai_blocksObservingPlayersThroughObserverServiceState() { SessionService sessionService = mock(SessionService.class); ObserverService observerService = mock(ObserverService.class); MiniHexedService hexedService = new MiniHexedService( - mock(Config.class), + mock(TomlXcoreConfig.class), sessionService, mock(PlayerDataRepository.class), mock(NetworkService.class), diff --git a/src/test/java/org/xcore/plugin/command/controller/client/SocialControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/client/SocialControllerTest.java new file mode 100644 index 00000000..8bc17b95 --- /dev/null +++ b/src/test/java/org/xcore/plugin/command/controller/client/SocialControllerTest.java @@ -0,0 +1,87 @@ +package org.xcore.plugin.command.controller.client; + +import com.ospx.flubundle.Bundle; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.xcore.plugin.cloud.XCoreSender; +import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; +import org.xcore.plugin.database.repository.PlayerDataRepository; +import org.xcore.plugin.localization.TranslatorLanguagesProvider; +import org.xcore.plugin.model.PlayerData; +import org.xcore.plugin.service.ChatFormatService; +import org.xcore.plugin.service.DiscordLinkService; +import org.xcore.plugin.service.NetworkService; +import org.xcore.plugin.service.TranslatorService; +import org.xcore.plugin.session.Session; +import org.xcore.plugin.session.SessionService; +import org.xcore.plugin.ui.MenuService; +import org.xcore.plugin.ui.menu.DiscordMenu; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; +import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SocialControllerTest { + + @Test + @DisplayName("global chat publishes server-scoped events using structured server name") + void globalChat_publishesServerScopedEventsUsingStructuredServerName() { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + + Session session = session("uuid-1", "[cyan]Tester", "Tester"); + SessionService sessionService = mock(SessionService.class); + when(sessionService.get("uuid-1")).thenReturn(session); + + NetworkService network = mock(NetworkService.class); + SocialController controller = new SocialController( + sessionService, + network, + config, + new GlobalConfig(), + mock(TranslatorLanguagesProvider.class), + mock(ChatFormatService.class), + mock(TranslatorService.class), + mock(DiscordLinkService.class), + mock(DiscordMenu.class) + ); + + XCoreSender sender = mock(XCoreSender.class); + when(sender.player()).thenReturn(session.player); + + controller.globalChat(sender, "he`llo"); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(network, org.mockito.Mockito.times(2)).post(eventCaptor.capture()); + + assertThat(eventCaptor.getAllValues()) + .containsExactly( + new ChatGlobalV1("[cyan]Tester", "he`llo", "mini-pvp"), + new ChatMessageV1("Tester", "[mini-pvp] he*llo", "global") + ); + } + + private Session session(String uuid, String coloredName, String plainName) { + mindustry.gen.Player player = mock(mindustry.gen.Player.class); + when(player.uuid()).thenReturn(uuid); + when(player.coloredName()).thenReturn(coloredName); + when(player.plainName()).thenReturn(plainName); + + PlayerData data = new PlayerData(); + data.uuid = uuid; + + return new Session( + new GlobalConfig(), + mock(Bundle.class), + mock(MenuService.class), + mock(PlayerDataRepository.class), + player, + data + ); + } +} diff --git a/src/test/java/org/xcore/plugin/command/controller/server/BadgeAdminControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/server/BadgeAdminControllerTest.java new file mode 100644 index 00000000..2620fdaf --- /dev/null +++ b/src/test/java/org/xcore/plugin/command/controller/server/BadgeAdminControllerTest.java @@ -0,0 +1,84 @@ +package org.xcore.plugin.command.controller.server; + +import com.ospx.flubundle.Bundle; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.xcore.plugin.cloud.XCoreSender; +import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; +import org.xcore.plugin.database.repository.PlayerDataRepository; +import org.xcore.plugin.model.PlayerData; +import org.xcore.plugin.service.NetworkService; +import org.xcore.plugin.service.PlayerDisplayService; +import org.xcore.plugin.session.Session; +import org.xcore.plugin.session.SessionService; +import org.xcore.plugin.ui.MenuService; +import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerBadgeInventoryChangedCommandV1; + +import java.util.HashSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BadgeAdminControllerTest { + + @Test + @DisplayName("badge grant publishes inventory change with structured server name") + void grant_publishesInventoryChangeWithStructuredServerName() { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + + PlayerDataRepository playerDataRepository = mock(PlayerDataRepository.class); + Session session = session("uuid-1", "Tester"); + SessionService sessionService = mock(SessionService.class); + when(sessionService.getAllCachedSnapshot()).thenReturn(List.of(session)); + when(sessionService.get("uuid-1")).thenReturn(session); + + NetworkService network = mock(NetworkService.class); + PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); + BadgeAdminController controller = new BadgeAdminController( + sessionService, + network, + playerDisplayService, + playerDataRepository, + config + ); + + controller.grant(mock(XCoreSender.class), "uuid-1", "translator"); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(PlayerBadgeInventoryChangedCommandV1.class); + verify(network).post(eventCaptor.capture()); + verify(playerDisplayService).refresh(session); + verify(playerDataRepository).replaceUnlockedBadges("uuid-1", java.util.Set.of("translator")); + verify(playerDataRepository).setActiveBadge("uuid-1", ""); + + assertThat(eventCaptor.getValue().playerUuid()).isEqualTo("uuid-1"); + assertThat(eventCaptor.getValue().activeBadge()).isEmpty(); + assertThat(eventCaptor.getValue().unlockedBadges()).containsExactly("translator"); + assertThat(eventCaptor.getValue().server()).isEqualTo("mini-pvp"); + } + + private Session session(String uuid, String nickname) { + mindustry.gen.Player player = mock(mindustry.gen.Player.class); + when(player.uuid()).thenReturn(uuid); + + PlayerData data = new PlayerData(); + data.uuid = uuid; + data.nickname = nickname; + data.unlockedBadges = new HashSet<>(); + data.activeBadge = ""; + + return new Session( + new GlobalConfig(), + mock(Bundle.class), + mock(MenuService.class), + mock(PlayerDataRepository.class), + player, + data + ); + } +} diff --git a/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java index c24a5b1e..a124f9f5 100644 --- a/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java +++ b/src/test/java/org/xcore/plugin/command/controller/server/DataControllerTest.java @@ -6,11 +6,11 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.xcore.plugin.cloud.XCoreSender; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.SerializationFactory; import org.xcore.plugin.config.ServerLocalConfigPathEditor; import org.xcore.plugin.config.ServerLocalConfigTomlRenderer; import org.xcore.plugin.config.ServerLocalConfigTomlStore; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.FindService; @@ -30,7 +30,7 @@ class DataControllerTest { void xconfigEdit_updatesConfigAndPersistsThroughTomlStore() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -40,9 +40,10 @@ void xconfigEdit_updatesConfigAndPersistsThroughTomlStore() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "playerLimit", "64"); - var configCaptor = ArgumentCaptor.forClass(Config.class); + var configCaptor = ArgumentCaptor.forClass(TomlXcoreConfig.class); verify(tomlStore).write(configCaptor.capture()); - assertThat(configCaptor.getValue().playerLimit).isEqualTo(64); + assertThat(configCaptor.getValue().server.playerLimit).isEqualTo(64); + assertThat(config.server.playerLimit).isEqualTo(64); } @Test @@ -50,7 +51,7 @@ void xconfigEdit_updatesConfigAndPersistsThroughTomlStore() { void xconfigEdit_supportsNestedTomlPaths() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -60,9 +61,10 @@ void xconfigEdit_supportsNestedTomlPaths() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "transport.redis.url", "redis://example:6379"); - var configCaptor = ArgumentCaptor.forClass(Config.class); + var configCaptor = ArgumentCaptor.forClass(TomlXcoreConfig.class); verify(tomlStore).write(configCaptor.capture()); - assertThat(configCaptor.getValue().redisUrl).isEqualTo("redis://example:6379"); + assertThat(configCaptor.getValue().transport.redis.url).isEqualTo("redis://example:6379"); + assertThat(config.transport.redis.url).isEqualTo("redis://example:6379"); } @Test @@ -70,7 +72,7 @@ void xconfigEdit_supportsNestedTomlPaths() { void xconfigEdit_supportsCommaSeparatedTranslationPipeline() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -80,10 +82,12 @@ void xconfigEdit_supportsCommaSeparatedTranslationPipeline() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "translation.pipeline", "google, openai, deepl"); - var configCaptor = ArgumentCaptor.forClass(Config.class); + var configCaptor = ArgumentCaptor.forClass(TomlXcoreConfig.class); verify(tomlStore).write(configCaptor.capture()); assertThat(configCaptor.getValue().translation.pipeline) .containsExactly("google", "openai", "deepl"); + assertThat(config.translation.pipeline) + .containsExactly("google", "openai", "deepl"); } @Test @@ -91,7 +95,7 @@ void xconfigEdit_supportsCommaSeparatedTranslationPipeline() { void xconfigEdit_supportsJsonArrayTranslationPipeline() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -101,10 +105,12 @@ void xconfigEdit_supportsJsonArrayTranslationPipeline() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "translation.pipeline", "[\"google\", \"openai\"]"); - var configCaptor = ArgumentCaptor.forClass(Config.class); + var configCaptor = ArgumentCaptor.forClass(TomlXcoreConfig.class); verify(tomlStore).write(configCaptor.capture()); assertThat(configCaptor.getValue().translation.pipeline) .containsExactly("google", "openai"); + assertThat(config.translation.pipeline) + .containsExactly("google", "openai"); } @Test @@ -112,7 +118,7 @@ void xconfigEdit_supportsJsonArrayTranslationPipeline() { void xconfigEdit_doesNotPersistWhenFieldIsMissing() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -122,8 +128,8 @@ void xconfigEdit_doesNotPersistWhenFieldIsMissing() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "missing_field", "value"); - verify(tomlStore, never()).write(any(Config.class)); - assertThat(config.playerLimit).isEqualTo(30); + verify(tomlStore, never()).write(any(TomlXcoreConfig.class)); + assertThat(config.server.playerLimit).isEqualTo(30); } @Test @@ -131,7 +137,7 @@ void xconfigEdit_doesNotPersistWhenFieldIsMissing() { void xconfigEdit_doesNotPersistWhenIntegerValueIsInvalid() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -141,8 +147,8 @@ void xconfigEdit_doesNotPersistWhenIntegerValueIsInvalid() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "playerLimit", "not-a-number"); - verify(tomlStore, never()).write(any(Config.class)); - assertThat(config.playerLimit).isEqualTo(30); + verify(tomlStore, never()).write(any(TomlXcoreConfig.class)); + assertThat(config.server.playerLimit).isEqualTo(30); } @Test @@ -150,7 +156,7 @@ void xconfigEdit_doesNotPersistWhenIntegerValueIsInvalid() { void xconfigEdit_doesNotPersistWhenBooleanValueIsInvalid() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -160,8 +166,8 @@ void xconfigEdit_doesNotPersistWhenBooleanValueIsInvalid() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "consoleEnabled", "maybe"); - verify(tomlStore, never()).write(any(Config.class)); - assertThat(config.consoleEnabled).isTrue(); + verify(tomlStore, never()).write(any(TomlXcoreConfig.class)); + assertThat(config.server.consoleEnabled).isTrue(); } @Test @@ -169,7 +175,7 @@ void xconfigEdit_doesNotPersistWhenBooleanValueIsInvalid() { void xconfigEdit_doesNotPersistWhenTranslationPipelineArrayIsMalformed() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -180,7 +186,7 @@ void xconfigEdit_doesNotPersistWhenTranslationPipelineArrayIsMalformed() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "translation.pipeline", "[\"google\","); - verify(tomlStore, never()).write(any(Config.class)); + verify(tomlStore, never()).write(any(TomlXcoreConfig.class)); assertThat(config.translation.pipeline).containsExactly("google"); } @@ -189,7 +195,7 @@ void xconfigEdit_doesNotPersistWhenTranslationPipelineArrayIsMalformed() { void xconfigEdit_rejectsDedicatedDisabledCommandPaths() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -199,9 +205,9 @@ void xconfigEdit_rejectsDedicatedDisabledCommandPaths() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "runtime.disabled_commands", "[\"help\"]"); - verify(tomlStore, never()).write(any(Config.class)); - verify(pathEditor, never()).update(any(Config.class), any(String.class), any(String.class)); - assertThat(config.disabledCommands).isEmpty(); + verify(tomlStore, never()).write(any(TomlXcoreConfig.class)); + verify(pathEditor, never()).update(any(TomlXcoreConfig.class), any(String.class), any(String.class)); + assertThat(config.runtime.disabledCommands).isEmpty(); } @Test @@ -209,7 +215,7 @@ void xconfigEdit_rejectsDedicatedDisabledCommandPaths() { void xconfigEdit_rejectsDedicatedDisabledFeaturePaths() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -219,9 +225,9 @@ void xconfigEdit_rejectsDedicatedDisabledFeaturePaths() { var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); controller.xconfigEdit(sender, "disabledFeatures", "[\"rtv\"]"); - verify(tomlStore, never()).write(any(Config.class)); - verify(pathEditor, never()).update(any(Config.class), any(String.class), any(String.class)); - assertThat(config.disabledFeatures).isEmpty(); + verify(tomlStore, never()).write(any(TomlXcoreConfig.class)); + verify(pathEditor, never()).update(any(TomlXcoreConfig.class), any(String.class), any(String.class)); + assertThat(config.runtime.disabledFeatures).isEmpty(); } @Test @@ -230,7 +236,7 @@ void editDataPreservesMongoId() { var repository = mock(PlayerDataRepository.class); var topMenuCacheService = mock(TopMenuCacheService.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); + var config = new TomlXcoreConfig(); var gson = new Gson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -264,10 +270,10 @@ void editDataPreservesMongoId() { void xconfigShow_rendersTomlShapedServerLocalConfig() { var repository = mock(PlayerDataRepository.class); var tomlStore = mock(ServerLocalConfigTomlStore.class); - var config = new Config(); - config.server = "alpha"; - config.playerLimit = 64; - config.redisUrl = "redis://example:6379"; + var config = new TomlXcoreConfig(); + config.server.name = "alpha"; + config.server.playerLimit = 64; + config.transport.redis.url = "redis://example:6379"; var gson = new SerializationFactory().prettyGson(); var find = mock(FindService.class); var sender = mock(XCoreSender.class); @@ -284,6 +290,6 @@ void xconfigShow_rendersTomlShapedServerLocalConfig() { controller.xconfigShow(sender); - verify(tomlStore, never()).write(any(Config.class)); + verify(tomlStore, never()).write(any(TomlXcoreConfig.class)); } } diff --git a/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java index 666319e6..dc2b6675 100644 --- a/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java +++ b/src/test/java/org/xcore/plugin/command/controller/server/MaintainControllerTest.java @@ -6,8 +6,8 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.xcore.plugin.cloud.XCoreSender; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.ServerLocalConfigTomlStore; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.protocol.generated.messages.chat.ChatMessages.PlayerDataCacheReloadCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ServerCommandExecuteCommandV1; @@ -34,7 +34,7 @@ void gcmdParsesCommaSeparatedTargets() { var pluginState = new PluginState(); var sessionService = mock(SessionService.class); var auditService = mock(MapIdentityAuditService.class); - var config = new Config(); + var serverLocalConfig = new TomlXcoreConfig(); var configFile = mock(Fi.class); var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); @@ -46,7 +46,7 @@ void gcmdParsesCommaSeparatedTargets() { pluginState, sessionService, auditService, - config, + serverLocalConfig, tomlStore, gson ); @@ -70,7 +70,7 @@ void gcmdFallsBackToAllServers() { var pluginState = new PluginState(); var sessionService = mock(SessionService.class); var auditService = mock(MapIdentityAuditService.class); - var config = new Config(); + var serverLocalConfig = new TomlXcoreConfig(); var configFile = mock(Fi.class); var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); @@ -82,7 +82,7 @@ void gcmdFallsBackToAllServers() { pluginState, sessionService, auditService, - config, + serverLocalConfig, tomlStore, gson ); @@ -106,7 +106,7 @@ void gcmdPreservesExclusionMode() { var pluginState = new PluginState(); var sessionService = mock(SessionService.class); var auditService = mock(MapIdentityAuditService.class); - var config = new Config(); + var serverLocalConfig = new TomlXcoreConfig(); var configFile = mock(Fi.class); var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); @@ -118,7 +118,7 @@ void gcmdPreservesExclusionMode() { pluginState, sessionService, auditService, - config, + serverLocalConfig, tomlStore, gson ); @@ -142,7 +142,7 @@ void disableCmd_normalizesCommandAndPersistsConfig() { var pluginState = new PluginState(); var sessionService = mock(SessionService.class); var auditService = mock(MapIdentityAuditService.class); - var config = new Config(); + var serverLocalConfig = new TomlXcoreConfig(); var configFile = mock(Fi.class); var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); @@ -154,14 +154,14 @@ void disableCmd_normalizesCommandAndPersistsConfig() { pluginState, sessionService, auditService, - config, + serverLocalConfig, tomlStore, gson ); controller.disableCmd(sender, " /Help Me "); - assertThat(config.disabledCommands).containsExactly("help me"); + assertThat(serverLocalConfig.runtime.disabledCommands).containsExactly("help me"); verify(configFile).writeString(anyString()); } @@ -173,7 +173,7 @@ void disableCmd_doesNotPersistProtectedCommands() { var pluginState = new PluginState(); var sessionService = mock(SessionService.class); var auditService = mock(MapIdentityAuditService.class); - var config = new Config(); + var serverLocalConfig = new TomlXcoreConfig(); var configFile = mock(Fi.class); var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); @@ -185,14 +185,14 @@ void disableCmd_doesNotPersistProtectedCommands() { pluginState, sessionService, auditService, - config, + serverLocalConfig, tomlStore, gson ); controller.disableCmd(sender, "disable-cmd nested"); - assertThat(config.disabledCommands).isEmpty(); + assertThat(serverLocalConfig.runtime.disabledCommands).isEmpty(); verify(configFile, never()).writeString(anyString()); } @@ -204,8 +204,8 @@ void enableFeature_removesDisabledFeatureAndPersistsConfig() { var pluginState = new PluginState(); var sessionService = mock(SessionService.class); var auditService = mock(MapIdentityAuditService.class); - var config = new Config(); - config.disabledFeatures.add("rtv"); + var serverLocalConfig = new TomlXcoreConfig(); + serverLocalConfig.runtime.disabledFeatures.add("rtv"); var configFile = mock(Fi.class); var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); @@ -217,14 +217,14 @@ void enableFeature_removesDisabledFeatureAndPersistsConfig() { pluginState, sessionService, auditService, - config, + serverLocalConfig, tomlStore, gson ); controller.enableFeature(sender, "rtv"); - assertThat(config.disabledFeatures).doesNotContain("rtv"); + assertThat(serverLocalConfig.runtime.disabledFeatures).doesNotContain("rtv"); verify(configFile).writeString(anyString()); } @@ -237,7 +237,8 @@ void deleteBots_invalidatesTopCacheWhenPlayersAreRemoved() { var sessionService = mock(SessionService.class); var auditService = mock(MapIdentityAuditService.class); var topMenuCacheService = mock(TopMenuCacheService.class); - var config = new Config(); + var serverLocalConfig = new TomlXcoreConfig(); + serverLocalConfig.server.name = "alpha"; var configFile = mock(Fi.class); var tomlStore = new ServerLocalConfigTomlStore(configFile); var gson = new Gson(); @@ -252,7 +253,7 @@ void deleteBots_invalidatesTopCacheWhenPlayersAreRemoved() { sessionService, auditService, topMenuCacheService, - config, + serverLocalConfig, tomlStore, gson ); @@ -261,6 +262,8 @@ void deleteBots_invalidatesTopCacheWhenPlayersAreRemoved() { verify(repository).deleteBots(); verify(topMenuCacheService).invalidateAll(); - verify(network).post(org.mockito.ArgumentMatchers.any(PlayerDataCacheReloadCommandV1.class)); + var captor = ArgumentCaptor.forClass(PlayerDataCacheReloadCommandV1.class); + verify(network).post(captor.capture()); + assertThat(captor.getValue().server()).isEqualTo("alpha"); } } diff --git a/src/test/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigServiceTest.java b/src/test/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigServiceTest.java new file mode 100644 index 00000000..78a533e2 --- /dev/null +++ b/src/test/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigServiceTest.java @@ -0,0 +1,43 @@ +package org.xcore.plugin.command.controller.server; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.xcore.plugin.config.ServerLocalConfigTomlStore; +import org.xcore.plugin.config.TomlXcoreConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RuntimeToggleConfigServiceTest { + + @Test + @DisplayName("disable stores normalized runtime command toggles on structured config") + void disable_storesRuntimeCommandTogglesOnStructuredConfig() { + var config = new TomlXcoreConfig(); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var service = new RuntimeToggleConfigService(config, tomlStore); + + var result = service.disable(RuntimeToggleConfigService.ToggleTarget.COMMAND, "help me"); + + assertThat(result.changed()).isTrue(); + assertThat(config.runtime.disabledCommands).containsExactly("help me"); + verify(tomlStore).write(any(TomlXcoreConfig.class)); + } + + @Test + @DisplayName("enable removes runtime feature toggles from structured config") + void enable_removesRuntimeFeatureTogglesFromStructuredConfig() { + var config = new TomlXcoreConfig(); + config.runtime.disabledFeatures.add("rtv"); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var service = new RuntimeToggleConfigService(config, tomlStore); + + var result = service.enable(RuntimeToggleConfigService.ToggleTarget.FEATURE, "rtv"); + + assertThat(result.changed()).isTrue(); + assertThat(config.runtime.disabledFeatures).doesNotContain("rtv"); + verify(tomlStore).write(any(TomlXcoreConfig.class)); + } +} diff --git a/src/test/java/org/xcore/plugin/command/controller/server/TranslationStatsControllerTest.java b/src/test/java/org/xcore/plugin/command/controller/server/TranslationStatsControllerTest.java index e5ddddac..d3e286d4 100644 --- a/src/test/java/org/xcore/plugin/command/controller/server/TranslationStatsControllerTest.java +++ b/src/test/java/org/xcore/plugin/command/controller/server/TranslationStatsControllerTest.java @@ -3,8 +3,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.plugin.cloud.XCoreSender; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationMetricsService; import java.util.LinkedHashMap; @@ -20,8 +20,8 @@ class TranslationStatsControllerTest { @Test @DisplayName("trstats queries global and per-provider translation metrics") void translationStats_queriesGlobalAndPerProviderMetrics() { - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; config.translation.pipeline = java.util.List.of("nvidia-mistral-small", "google"); GlobalConfig globalConfig = new GlobalConfig(); diff --git a/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java b/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java index dfbb560e..789ceb85 100644 --- a/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java +++ b/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java @@ -39,15 +39,17 @@ void cleanConfigFiles() throws IOException { } @Test - @DisplayName("config creates missing xcore.toml from defaults and loads normalized Config") + @DisplayName("server-local config creates missing xcore.toml from defaults and projects normalized Config") void config_createsMissingXcoreTomlFromDefaultsAndLoadsNormalizedConfig() { // Arrange ConfigFactory factory = new ConfigFactory(); // Act - Config config = factory.config(prettyGson); + TomlXcoreConfig serverLocalConfig = factory.serverLocalConfig(prettyGson); + Config config = factory.config(serverLocalConfig); // Assert + assertThat(serverLocalConfig.server.name).isEqualTo("server"); assertThat(config.server).isEqualTo("server"); assertThat(config.disabledCommands).isNotNull().isEmpty(); assertThat(config.disabledFeatures).isNotNull().isEmpty(); @@ -60,7 +62,7 @@ void config_createsMissingXcoreTomlFromDefaultsAndLoadsNormalizedConfig() { } @Test - @DisplayName("config loads xcore.toml when both TOML and legacy JSON exist") + @DisplayName("server-local config loads xcore.toml when both TOML and legacy JSON exist") void config_tomlTakesPrecedenceOverLegacyJson() throws IOException { // Arrange Path tomlPath = tempDir.resolve("xcore.toml"); @@ -81,14 +83,16 @@ void config_tomlTakesPrecedenceOverLegacyJson() throws IOException { ConfigFactory factory = new ConfigFactory(); // Act - Config config = factory.config(prettyGson); + TomlXcoreConfig serverLocalConfig = factory.serverLocalConfig(prettyGson); + Config config = factory.config(serverLocalConfig); // Assert + assertThat(serverLocalConfig.server.name).isEqualTo("toml-server"); assertThat(config.server).isEqualTo("toml-server"); } @Test - @DisplayName("config falls back to legacy xcconfig.json when xcore.toml is absent") + @DisplayName("server-local config falls back to legacy xcconfig.json when xcore.toml is absent") void config_legacyJsonFallbackWorksWhenTomlAbsent() throws IOException { // Arrange Path jsonPath = tempDir.resolve("xcconfig.json"); @@ -106,9 +110,11 @@ void config_legacyJsonFallbackWorksWhenTomlAbsent() throws IOException { ConfigFactory factory = new ConfigFactory(); // Act - Config config = factory.config(prettyGson); + TomlXcoreConfig serverLocalConfig = factory.serverLocalConfig(prettyGson); + Config config = factory.config(serverLocalConfig); // Assert + assertThat(serverLocalConfig.server.name).isEqualTo("legacy-server"); assertThat(config.server).isEqualTo("legacy-server"); assertThat(config.disabledCommands).isNotNull().isEmpty(); assertThat(config.disabledFeatures).isNotNull().isEmpty(); diff --git a/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java b/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java index a52e1141..82cbe1c7 100644 --- a/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java +++ b/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java @@ -39,12 +39,12 @@ void loadXcoreConfig_returnsTomlSource_whenTomlExists() throws IOException { """); Fi dataDir = new Fi(tempDir.toFile()); - ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); assertThat(result.file.name()).isEqualTo("xcore.toml"); - assertThat(result.config.server).isEqualTo("test-server"); - assertThat(result.config.playerLimit).isEqualTo(42); + assertThat(result.config.server.name).isEqualTo("test-server"); + assertThat(result.config.server.playerLimit).isEqualTo(42); } @Test @@ -61,7 +61,7 @@ void loadXcoreConfig_returnsLegacyJsonSource_whenOnlyJsonExists() throws IOExcep ConfigTomlLoader.setBackupTimestampSupplier(() -> LocalDateTime.of(2026, 5, 24, 16, 30, 45)); Fi dataDir = new Fi(tempDir.toFile()); - ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.MIGRATED); assertThat(result.file.name()).isEqualTo("xcore.toml"); @@ -70,21 +70,21 @@ void loadXcoreConfig_returnsLegacyJsonSource_whenOnlyJsonExists() throws IOExcep assertThat(tempDir.resolve("xcconfig.json")).doesNotExist(); assertThat(tempDir.resolve("xcore.toml")).exists(); assertThat(tempDir.resolve("xcconfig.json.bak-20260524-163045")).exists(); - assertThat(result.config.server).isEqualTo("legacy-server"); - assertThat(result.config.playerLimit).isEqualTo(99); + assertThat(result.config.server.name).isEqualTo("legacy-server"); + assertThat(result.config.server.playerLimit).isEqualTo(99); } @Test @DisplayName("loadXcoreConfig returns DEFAULT_TEMPLATE source and creates file when neither exists") void loadXcoreConfig_returnsDefaultTemplateSource_whenNeitherExists() { Fi dataDir = new Fi(tempDir.toFile()); - ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.DEFAULT_TEMPLATE); assertThat(result.file.name()).isEqualTo("xcore.toml"); assertThat(result.file.exists()).isTrue(); - assertThat(result.config.server).isEqualTo("server"); - assertThat(result.config.playerLimit).isEqualTo(30); + assertThat(result.config.server.name).isEqualTo("server"); + assertThat(result.config.server.playerLimit).isEqualTo(30); } @Test @@ -106,10 +106,10 @@ void loadXcoreConfig_prefersTomlOverLegacyJson() throws IOException { """); Fi dataDir = new Fi(tempDir.toFile()); - ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig(dataDir, gson); assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); - assertThat(result.config.server).isEqualTo("toml-wins"); + assertThat(result.config.server.name).isEqualTo("toml-wins"); } @Test diff --git a/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java b/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java index b239d68d..47aef83b 100644 --- a/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java @@ -10,37 +10,46 @@ class ServerLocalConfigPathEditorTest { private final ServerLocalConfigPathEditor editor = new ServerLocalConfigPathEditor(new SerializationFactory().prettyGson()); + @Test + @DisplayName("update supports TomlXcoreConfig directly") + void update_supportsTomlXcoreConfigDirectly() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "server.player_limit", "64"); + + assertThat(updated).isNotNull(); + assertThat(updated.server.playerLimit).isEqualTo(64); + } + @Test @DisplayName("update supports legacy alias paths") void update_supportsLegacyAliasPaths() { - Config updated = editor.update(new Config(), "playerLimit", "64"); + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "playerLimit", "64"); assertThat(updated).isNotNull(); - assertThat(updated.playerLimit).isEqualTo(64); + assertThat(updated.server.playerLimit).isEqualTo(64); } @Test @DisplayName("update supports canonical TOML dotted paths") void update_supportsCanonicalTomlDottedPaths() { - Config updated = editor.update(new Config(), "server.player_limit", "48"); + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "server.player_limit", "48"); assertThat(updated).isNotNull(); - assertThat(updated.playerLimit).isEqualTo(48); + assertThat(updated.server.playerLimit).isEqualTo(48); } @Test @DisplayName("update supports nested transport redis dotted paths") void update_supportsNestedTransportRedisDottedPaths() { - Config updated = editor.update(new Config(), "transport.redis.url", "redis://example:6379"); + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "transport.redis.url", "redis://example:6379"); assertThat(updated).isNotNull(); - assertThat(updated.redisUrl).isEqualTo("redis://example:6379"); + assertThat(updated.transport.redis.url).isEqualTo("redis://example:6379"); } @Test @DisplayName("update parses comma separated translation pipeline and drops blanks") void update_parsesCommaSeparatedTranslationPipelineAndDropsBlanks() { - Config updated = editor.update(new Config(), "translation.pipeline", " google , , openai , deepl "); + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "translation.pipeline", " google , , openai , deepl "); assertThat(updated).isNotNull(); assertThat(updated.translation.pipeline).containsExactly("google", "openai", "deepl"); @@ -49,16 +58,25 @@ void update_parsesCommaSeparatedTranslationPipelineAndDropsBlanks() { @Test @DisplayName("update parses JSON array translation pipeline") void update_parsesJsonArrayTranslationPipeline() { - Config updated = editor.update(new Config(), "translation.pipeline", "[\"google\", \"openai\"]"); + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "translation.pipeline", "[\"google\", \"openai\"]"); + + assertThat(updated).isNotNull(); + assertThat(updated.translation.pipeline).containsExactly("google", "openai"); + } + + @Test + @DisplayName("update mutates TomlXcoreConfig pipeline directly") + void update_mutatesTomlXcoreConfigPipelineDirectly() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "translation.pipeline", "google, openai"); assertThat(updated).isNotNull(); assertThat(updated.translation.pipeline).containsExactly("google", "openai"); } @Test - @DisplayName("update returns null for unsupported path") - void update_returnsNullForUnsupportedPath() { - Config updated = editor.update(new Config(), "missing.path", "value"); + @DisplayName("update returns null for unsupported path on TomlXcoreConfig") + void update_returnsNullForUnsupportedPathOnTomlXcoreConfig() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "missing.path", "value"); assertThat(updated).isNull(); } @@ -66,7 +84,7 @@ void update_returnsNullForUnsupportedPath() { @Test @DisplayName("update throws friendly exception for invalid boolean value") void update_throwsFriendlyExceptionForInvalidBooleanValue() { - assertThatThrownBy(() -> editor.update(new Config(), "consoleEnabled", "maybe")) + assertThatThrownBy(() -> editor.update(new TomlXcoreConfig(), "consoleEnabled", "maybe")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid value 'maybe' for 'console_enabled' (expected true or false)."); } @@ -74,7 +92,7 @@ void update_throwsFriendlyExceptionForInvalidBooleanValue() { @Test @DisplayName("update throws friendly exception for invalid integer value") void update_throwsFriendlyExceptionForInvalidIntegerValue() { - assertThatThrownBy(() -> editor.update(new Config(), "playerLimit", "not-a-number")) + assertThatThrownBy(() -> editor.update(new TomlXcoreConfig(), "playerLimit", "not-a-number")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid value 'not-a-number' for 'player_limit' (expected integer number).") .cause() @@ -84,7 +102,7 @@ void update_throwsFriendlyExceptionForInvalidIntegerValue() { @Test @DisplayName("update throws friendly exception for invalid long value") void update_throwsFriendlyExceptionForInvalidLongValue() { - assertThatThrownBy(() -> editor.update(new Config(), "discordChannelId", "not-a-long")) + assertThatThrownBy(() -> editor.update(new TomlXcoreConfig(), "discordChannelId", "not-a-long")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid value 'not-a-long' for 'channel_id' (expected integer number).") .cause() @@ -94,7 +112,7 @@ void update_throwsFriendlyExceptionForInvalidLongValue() { @Test @DisplayName("update throws friendly exception for malformed translation pipeline JSON") void update_throwsFriendlyExceptionForMalformedTranslationPipelineJson() { - assertThatThrownBy(() -> editor.update(new Config(), "translation.pipeline", "[\"google\",")) + assertThatThrownBy(() -> editor.update(new TomlXcoreConfig(), "translation.pipeline", "[\"google\",")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Invalid value '[\"google\",' for 'translation.pipeline' (expected a comma-separated list or JSON string array).") .cause() diff --git a/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlRendererTest.java b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlRendererTest.java new file mode 100644 index 00000000..95b4c034 --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlRendererTest.java @@ -0,0 +1,35 @@ +package org.xcore.plugin.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ServerLocalConfigTomlRendererTest { + + private final ServerLocalConfigTomlRenderer renderer = new ServerLocalConfigTomlRenderer(); + + @Test + @DisplayName("render outputs TOML for TomlXcoreConfig directly") + void render_outputsTomlForTomlXcoreConfigDirectly() { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "alpha"; + config.server.playerLimit = 64; + config.transport.redis.url = "redis://example:6379"; + + assertThat(renderer.render(config)) + .contains("version = 1") + .contains("server.name = 'alpha'") + .contains("server.player_limit = 64") + .contains("transport.redis.url = 'redis://example:6379'"); + } + + @Test + @DisplayName("render throws when TomlXcoreConfig is null") + void render_throwsWhenTomlXcoreConfigIsNull() { + assertThatThrownBy(() -> renderer.render((TomlXcoreConfig) null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("config must not be null"); + } +} diff --git a/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java index 0c91eadf..8fb47586 100644 --- a/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java @@ -19,19 +19,19 @@ class ServerLocalConfigTomlStoreTest { Path tempDir; @Test - @DisplayName("write persists Config to xcore.toml and round-trips correctly") - void write_persistsConfig_andRoundTrips() throws IOException { + @DisplayName("write persists TomlXcoreConfig directly and round-trips correctly") + void write_persistsTomlXcoreConfig_andRoundTrips() throws IOException { Path tomlPath = tempDir.resolve("xcore.toml"); Fi tomlFile = new Fi(tomlPath.toFile()); ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); - Config config = new Config(); - config.server = "test-server"; - config.playerLimit = 42; - config.consoleEnabled = false; - config.publicHostOverride = "192.168.1.1"; - config.disabledCommands = Set.of("rtv", "maps"); - config.disabledFeatures = Set.of("chat"); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "test-server"; + config.server.playerLimit = 42; + config.server.consoleEnabled = false; + config.server.publicHostOverride = "192.168.1.1"; + config.runtime.disabledCommands = Set.of("rtv", "maps"); + config.runtime.disabledFeatures = Set.of("chat"); store.write(config); @@ -39,38 +39,37 @@ void write_persistsConfig_andRoundTrips() throws IOException { String written = Files.readString(tomlPath); assertThat(written).isNotBlank(); - // Round-trip: load back via ConfigTomlLoader - ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( new Fi(tempDir.toFile()), new SerializationFactory().prettyGson() ); - assertThat(result.config.server).isEqualTo("test-server"); - assertThat(result.config.playerLimit).isEqualTo(42); - assertThat(result.config.consoleEnabled).isFalse(); - assertThat(result.config.publicHostOverride).isEqualTo("192.168.1.1"); - assertThat(result.config.disabledCommands).containsExactlyInAnyOrder("rtv", "maps"); - assertThat(result.config.disabledFeatures).containsExactly("chat"); + assertThat(result.config.server.name).isEqualTo("test-server"); + assertThat(result.config.server.playerLimit).isEqualTo(42); + assertThat(result.config.server.consoleEnabled).isFalse(); + assertThat(result.config.server.publicHostOverride).isEqualTo("192.168.1.1"); + assertThat(result.config.runtime.disabledCommands).containsExactlyInAnyOrder("rtv", "maps"); + assertThat(result.config.runtime.disabledFeatures).containsExactly("chat"); } @Test - @DisplayName("write normalizes config before persisting") - void write_normalizesBeforePersisting() { + @DisplayName("write normalizes TomlXcoreConfig before persisting") + void write_normalizesTomlXcoreConfigBeforePersisting() { Path tomlPath = tempDir.resolve("xcore.toml"); Fi tomlFile = new Fi(tomlPath.toFile()); ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); - Config config = new Config(); - config.server = null; // will normalize to "server" via TomlXcoreConfig - config.disabledCommands = null; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = null; + config.runtime.disabledCommands = null; store.write(config); - ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( new Fi(tempDir.toFile()), new SerializationFactory().prettyGson() ); - assertThat(result.config.server).isEqualTo("server"); - assertThat(result.config.disabledCommands).isNotNull().isEmpty(); + assertThat(result.config.server.name).isEqualTo("server"); + assertThat(result.config.runtime.disabledCommands).isNotNull().isEmpty(); } @Test @@ -88,7 +87,7 @@ void write_throws_whenConfigIsNull() { Fi tomlFile = new Fi(tomlPath.toFile()); ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); - assertThatThrownBy(() -> store.write(null)) + assertThatThrownBy(() -> store.write((TomlXcoreConfig) null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("config must not be null"); } diff --git a/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java b/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java index 35999f79..faf8022c 100644 --- a/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java +++ b/src/test/java/org/xcore/plugin/config/TomlSecretsConfigTest.java @@ -52,8 +52,8 @@ void freshInstance_hasDefaultsMatchingLegacyGlobalConfig() { assertThat(google.type).isEqualTo("google"); assertThat(google.enabled).isTrue(); assertThat(google.apiKey).isEqualTo(""); - assertThat(google.baseUrl).isEqualTo(""); - assertThat(google.model).isEqualTo(""); + assertThat(google.baseUrl).isEqualTo("https://api.openai.com/v1"); + assertThat(google.model).isEqualTo("gpt-5.4"); assertThat(google.apiMode).isEqualTo(""); assertThat(google.organization).isEqualTo(""); assertThat(google.project).isEqualTo(""); @@ -132,8 +132,8 @@ void normalize_repairsInvalidProviderFields() { TomlSecretsConfig.TranslationSection.ProviderConfig normalized = toml.translation.providers.get("llm"); assertThat(normalized.type).isEqualTo("google"); - assertThat(normalized.baseUrl).isEqualTo(""); - assertThat(normalized.model).isEqualTo(""); + assertThat(normalized.baseUrl).isEqualTo("https://api.openai.com/v1"); + assertThat(normalized.model).isEqualTo("gpt-5.4"); assertThat(normalized.timeoutSeconds).isEqualTo(15); assertThat(normalized.maxRetries).isEqualTo(1); assertThat(normalized.supportedLanguages).isNotNull().isEmpty(); diff --git a/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java b/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java index a51a9627..b318dd95 100644 --- a/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java +++ b/src/test/java/org/xcore/plugin/event/NetEventServiceTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatMessageV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.event.net.admin.AdminRequestHandler; import org.xcore.plugin.event.net.chat.ChatMessageHandler; import org.xcore.plugin.event.net.chat.VoteChatInterceptor; @@ -38,7 +38,7 @@ class NetEventServiceTest { @DisplayName("chat muted player does not translate or publish message") void chatMutedPlayer_doesNotTranslateOrPublish() { SessionService sessionService = mock(SessionService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); TranslatorService translatorService = mock(TranslatorService.class); NetworkService network = mock(NetworkService.class); VoteService voteService = mock(VoteService.class); @@ -83,8 +83,8 @@ void chatMutedPlayer_doesNotTranslateOrPublish() { @DisplayName("chat happy path formats translates and publishes message") void chatHappyPath_formatsTranslatesAndPublishes() { SessionService sessionService = mock(SessionService.class); - Config config = new Config(); - config.server = "main"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "main"; TranslatorService translatorService = mock(TranslatorService.class); NetworkService network = mock(NetworkService.class); VoteService voteService = mock(VoteService.class); @@ -131,7 +131,7 @@ void chatHappyPath_formatsTranslatesAndPublishes() { @DisplayName("connect packet ignores already kicked connection") void connectPacket_ignoresAlreadyKickedConnection() { SessionService sessionService = mock(SessionService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); TranslatorService translatorService = mock(TranslatorService.class); NetworkService network = mock(NetworkService.class); VoteService voteService = mock(VoteService.class); @@ -174,7 +174,7 @@ void connectPacket_ignoresAlreadyKickedConnection() { @DisplayName("connect packet closes connection on silent deny") void connectPacket_closesOnSilentDeny() { SessionService sessionService = mock(SessionService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); TranslatorService translatorService = mock(TranslatorService.class); NetworkService network = mock(NetworkService.class); VoteService voteService = mock(VoteService.class); @@ -217,7 +217,7 @@ void connectPacket_closesOnSilentDeny() { @DisplayName("connect filter accepts allowed ip without changing counters") void connectFilter_acceptsAllowedIp() { SessionService sessionService = mock(SessionService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); TranslatorService translatorService = mock(TranslatorService.class); NetworkService network = mock(NetworkService.class); VoteService voteService = mock(VoteService.class); @@ -260,7 +260,7 @@ void connectFilter_acceptsAllowedIp() { @DisplayName("connect filter rejects blocked ip and increments counters") void connectFilter_rejectsBlockedIpAndIncrementsCounters() { SessionService sessionService = mock(SessionService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); TranslatorService translatorService = mock(TranslatorService.class); NetworkService network = mock(NetworkService.class); VoteService voteService = mock(VoteService.class); diff --git a/src/test/java/org/xcore/plugin/event/TransportServiceTest.java b/src/test/java/org/xcore/plugin/event/TransportServiceTest.java index eb0cc2da..73b19634 100644 --- a/src/test/java/org/xcore/plugin/event/TransportServiceTest.java +++ b/src/test/java/org/xcore/plugin/event/TransportServiceTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.event.transport.ChatTransportHandler; import org.xcore.plugin.event.transport.DiscordLinkTransportHandler; import org.xcore.plugin.event.transport.MapTransportHandler; @@ -28,8 +28,8 @@ class TransportServiceTest { @DisplayName("resolve host address returns configured override without contacting resolver") void resolveHostAddress_returnsConfiguredOverrideWithoutContactingResolver() { // Arrange - Config config = new Config(); - config.publicHostOverride = " play.xcore.example "; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.publicHostOverride = " play.xcore.example "; TestTransportService service = new TestTransportService(config); // Act @@ -44,7 +44,7 @@ void resolveHostAddress_returnsConfiguredOverrideWithoutContactingResolver() { @DisplayName("resolve host address caches successful resolver response") void resolveHostAddress_cachesSuccessfulResolverResponse() { // Arrange - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); TestTransportService service = new TestTransportService(config); service.enqueueConnection(new StubHttpURLConnection("198.51.100.24\n")); @@ -62,7 +62,7 @@ void resolveHostAddress_cachesSuccessfulResolverResponse() { @DisplayName("resolve host address backs off after resolver failure until retry window expires") void resolveHostAddress_backsOffAfterResolverFailureUntilRetryWindowExpires() { // Arrange - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); TestTransportService service = new TestTransportService(config); service.setCurrentTimeMillis(10_000L); service.setFailureBackoffMs(5_000L); @@ -90,7 +90,7 @@ private static final class TestTransportService extends TransportService { private long failureBackoffMs = HOST_RESOLUTION_FAILURE_BACKOFF_MS; private int openConnectionCount; - private TestTransportService(Config config) { + private TestTransportService(TomlXcoreConfig config) { super( mock(ChatTransportHandler.class), mock(DiscordLinkTransportHandler.class), diff --git a/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java b/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java index f9d38661..6fac8a02 100644 --- a/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/handler/ConnectionHandlerTest.java @@ -16,8 +16,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.AdminDataRepository; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; @@ -74,8 +74,8 @@ void onPlayerJoin_persistsNicknameWithChangedIp_andRevokesUnconfirmedAdmin() { DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); ObserverService observerService = mock(ObserverService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; GlobalConfig globalConfig = new GlobalConfig(); ConnectionHandler handler = new ConnectionHandler( diff --git a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java index 79c88781..f37df29c 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ChatTransportHandlerTest.java @@ -6,7 +6,7 @@ import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatDiscordIngressCommandV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatGlobalV1; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.Session; import org.xcore.plugin.service.NetworkService; @@ -35,8 +35,8 @@ void globalChatEvent_isBroadcastOnlyToPlayersWithGlobalChatEnabled() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); PrivateMessageService privateMessageService = mock(PrivateMessageService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ChatTransportHandler handler = new ChatTransportHandler(network, sessionService, privateMessageService, config); @@ -65,8 +65,8 @@ void discordRelayEvent_isIgnoredForOtherServers() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); PrivateMessageService privateMessageService = mock(PrivateMessageService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ChatTransportHandler handler = new ChatTransportHandler(network, sessionService, privateMessageService, config); @@ -87,8 +87,8 @@ void privateChatEvent_isDeliveredOnlyForRemoteServers() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); PrivateMessageService privateMessageService = mock(PrivateMessageService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ChatTransportHandler handler = new ChatTransportHandler(network, sessionService, privateMessageService, config); @@ -115,8 +115,8 @@ void privateChatEvent_isIgnoredForSameServer() { NetworkService network = mock(NetworkService.class); SessionService sessionService = mock(SessionService.class); PrivateMessageService privateMessageService = mock(PrivateMessageService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ChatTransportHandler handler = new ChatTransportHandler(network, sessionService, privateMessageService, config); diff --git a/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java index db2edbe7..75e9f6b6 100644 --- a/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/DiscordLinkTransportHandlerTest.java @@ -3,7 +3,6 @@ import arc.func.Cons; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; import org.xcore.plugin.event.TransportEvents; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; @@ -36,10 +35,8 @@ void discordLinkConfirmCommand_confirmsLinkAndNotifiesOnlinePlayer() { NetworkService network = mock(NetworkService.class); DiscordLinkService discordLinkService = mock(DiscordLinkService.class); SessionService sessionService = mock(SessionService.class); - Config config = new Config(); - config.server = "mini-pvp"; - DiscordLinkTransportHandler handler = new DiscordLinkTransportHandler(network, discordLinkService, sessionService, config); + DiscordLinkTransportHandler handler = new DiscordLinkTransportHandler(network, discordLinkService, sessionService); Map, Cons> listeners = new HashMap<>(); doAnswer(invocation -> { @@ -75,10 +72,8 @@ void discordUnlinkCommand_updatesOfflinePlayerDataWithoutOnlineSession() { NetworkService network = mock(NetworkService.class); DiscordLinkService discordLinkService = mock(DiscordLinkService.class); SessionService sessionService = mock(SessionService.class); - Config config = new Config(); - config.server = "mini-pvp"; - DiscordLinkTransportHandler handler = new DiscordLinkTransportHandler(network, discordLinkService, sessionService, config); + DiscordLinkTransportHandler handler = new DiscordLinkTransportHandler(network, discordLinkService, sessionService); Map, Cons> listeners = new HashMap<>(); doAnswer(invocation -> { diff --git a/src/test/java/org/xcore/plugin/event/transport/MapTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/MapTransportHandlerTest.java new file mode 100644 index 00000000..e4237f35 --- /dev/null +++ b/src/test/java/org/xcore/plugin/event/transport/MapTransportHandlerTest.java @@ -0,0 +1,203 @@ +package org.xcore.plugin.event.transport; + +import arc.func.Cons; +import arc.files.Fi; +import arc.struct.Seq; +import arc.struct.StringMap; +import mindustry.Vars; +import mindustry.game.Gamemode; +import mindustry.game.Rules; +import mindustry.maps.Maps; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.xcore.plugin.config.TomlXcoreConfig; +import org.xcore.plugin.database.repository.MapDataRepository; +import org.xcore.plugin.service.MapService; +import org.xcore.plugin.service.NetworkService; +import org.xcore.plugin.service.network.RedisNetworkBackend; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsListRequestV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsLoadCommandV1; +import org.xcore.protocol.generated.messages.maps.MapsMessages.MapsRemoveRequestV1; +import org.xcore.protocol.generated.shared.MapFileSourceV1; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class MapTransportHandlerTest { + + private Maps originalMaps; + private mindustry.core.GameState originalState; + + @BeforeEach + void setUp() { + originalMaps = Vars.maps; + originalState = Vars.state; + } + + @AfterEach + void tearDown() { + Vars.maps = originalMaps; + Vars.state = originalState; + } + + @Test + @DisplayName("maps list request is ignored for other servers") + void mapsListRequest_isIgnoredForOtherServers() { + NetworkService network = mock(NetworkService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + MapService mapService = mock(MapService.class); + MapDataRepository mapDataRepository = mock(MapDataRepository.class); + + MapTransportHandler handler = new MapTransportHandler(network, config, mapService, mapDataRepository); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + handler.registerListeners(); + + listener(listeners, MapsListRequestV1.class) + .get(new MapsListRequestV1("other-server")); + + verifyNoInteractions(mapService); + verifyNoInteractions(mapDataRepository); + verify(network, never()).respond(any(), any()); + } + + @Test + @DisplayName("maps list request is handled for same server") + void mapsListRequest_isHandledForSameServer() { + NetworkService network = mock(NetworkService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + MapService mapService = mock(MapService.class); + MapDataRepository mapDataRepository = mock(MapDataRepository.class); + + Maps maps = mock(Maps.class); + Vars.maps = maps; + when(maps.customMaps()).thenReturn(Seq.with()); + + Vars.state = new mindustry.core.GameState(); + Vars.state.rules = mock(Rules.class); + when(Vars.state.rules.mode()).thenReturn(Gamemode.pvp); + + MapTransportHandler handler = new MapTransportHandler(network, config, mapService, mapDataRepository); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + handler.registerListeners(); + + listener(listeners, MapsListRequestV1.class) + .get(new MapsListRequestV1("mini-pvp")); + + verify(network).respond(any(), any()); + } + + @Test + @DisplayName("maps remove request is ignored for other servers") + void mapsRemoveRequest_isIgnoredForOtherServers() { + NetworkService network = mock(NetworkService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + MapService mapService = mock(MapService.class); + MapDataRepository mapDataRepository = mock(MapDataRepository.class); + + MapTransportHandler handler = new MapTransportHandler(network, config, mapService, mapDataRepository); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + handler.registerListeners(); + + listener(listeners, MapsRemoveRequestV1.class) + .get(new MapsRemoveRequestV1("other-server", "test.msav")); + + verifyNoInteractions(mapService); + verify(network, never()).respond(any(), any()); + } + + @Test + @DisplayName("maps remove request is handled for same server") + void mapsRemoveRequest_isHandledForSameServer() { + NetworkService network = mock(NetworkService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + MapService mapService = mock(MapService.class); + MapDataRepository mapDataRepository = mock(MapDataRepository.class); + + mindustry.maps.Map map = new mindustry.maps.Map( + new Fi("test.msav"), + 100, + 100, + StringMap.of("name", "Test", "author", "author"), + true + ); + when(mapService.findMapByFileName("test.msav")).thenReturn(map); + + Maps maps = mock(Maps.class); + Vars.maps = maps; + + MapTransportHandler handler = new MapTransportHandler(network, config, mapService, mapDataRepository); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + handler.registerListeners(); + + listener(listeners, MapsRemoveRequestV1.class) + .get(new MapsRemoveRequestV1("mini-pvp", "test.msav")); + + verify(maps).removeMap(map); + verify(maps).reload(); + verify(network).respond(any(), any()); + } + + @Test + @DisplayName("maps load command is ignored for other servers") + void mapsLoadCommand_isIgnoredForOtherServers() { + NetworkService network = mock(NetworkService.class); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + MapService mapService = mock(MapService.class); + MapDataRepository mapDataRepository = mock(MapDataRepository.class); + + MapTransportHandler handler = new MapTransportHandler(network, config, mapService, mapDataRepository); + + Map, Cons> listeners = new HashMap<>(); + captureListeners(network, listeners); + + handler.registerListeners(); + + listener(listeners, MapsLoadCommandV1.class) + .get(new MapsLoadCommandV1("other-server", List.of( + new MapFileSourceV1("https://example/maps/a.msav", "a.msav") + ))); + + verifyNoInteractions(mapService); + verifyNoInteractions(mapDataRepository); + } + + private static void captureListeners(NetworkService network, Map, Cons> listeners) { + doAnswer(invocation -> { + listeners.put(invocation.getArgument(0), invocation.getArgument(1)); + return mock(RedisNetworkBackend.Subscription.class); + }).when(network).subscribe(any(), any()); + } + + @SuppressWarnings("unchecked") + private static Cons listener(Map, Cons> listeners, Class type) { + return (Cons) listeners.get(type); + } +} diff --git a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java index edfcdfc7..c5e5abb3 100644 --- a/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java +++ b/src/test/java/org/xcore/plugin/event/transport/ModerationTransportHandlerTest.java @@ -10,8 +10,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.service.DiscordAdminAccessService; @@ -69,8 +69,8 @@ void discordAdminAccessCommand_appliesPersistedAdminFlags() { PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); @@ -104,8 +104,8 @@ void discordAdminRevokeCommand_clearsPersistedAdminFlags() { PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); @@ -139,8 +139,8 @@ void playerSessionCommands_updateSessionStateAndRefreshDisplay() { PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); @@ -185,8 +185,8 @@ void badgeInventoryCommand_updatesUnlockedBadgesAndRefreshesDisplay() { PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); @@ -225,8 +225,8 @@ void passwordResetCommand_clearsPasswordWithoutRefresh() { PlayerDisplayService playerDisplayService = mock(PlayerDisplayService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; ModerationTransportHandler handler = new ModerationTransportHandler(network, sessionService, config, playerDisplayService, discordAdminAccessService); diff --git a/src/test/java/org/xcore/plugin/gamemode/ObserverFlowRegressionTest.java b/src/test/java/org/xcore/plugin/gamemode/ObserverFlowRegressionTest.java index d17e7380..2ddb065a 100644 --- a/src/test/java/org/xcore/plugin/gamemode/ObserverFlowRegressionTest.java +++ b/src/test/java/org/xcore/plugin/gamemode/ObserverFlowRegressionTest.java @@ -16,7 +16,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.event.net.connect.PlayerConnectionBootstrap; import org.xcore.plugin.gamemode.hexed.MiniHexedService; @@ -110,7 +110,7 @@ void hexedKillTeam_routesEliminatedPlayersThroughObserverService() { SessionService sessionService = mock(SessionService.class); ObserverService observerService = mock(ObserverService.class); MiniHexedService service = new MiniHexedService( - mock(Config.class), + mock(TomlXcoreConfig.class), sessionService, mock(PlayerDataRepository.class), mock(NetworkService.class), diff --git a/src/test/java/org/xcore/plugin/gamemode/pvp/MiniPvPRoundStateTest.java b/src/test/java/org/xcore/plugin/gamemode/pvp/MiniPvPRoundStateTest.java index cb054fe3..604a1675 100644 --- a/src/test/java/org/xcore/plugin/gamemode/pvp/MiniPvPRoundStateTest.java +++ b/src/test/java/org/xcore/plugin/gamemode/pvp/MiniPvPRoundStateTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.service.LeaderboardService; import org.xcore.plugin.service.TopMenuCacheService; @@ -20,7 +20,7 @@ class MiniPvPRoundStateTest { void clearRoundState_clearsStaleObserverRestoreStateForDefeatedPlayers() { ObserverService observerService = mock(ObserverService.class); MiniPvP miniPvP = new MiniPvP( - mock(Config.class), + mock(TomlXcoreConfig.class), mock(SessionService.class), mock(PlayerDataRepository.class), mock(LeaderboardService.class), diff --git a/src/test/java/org/xcore/plugin/localization/OpenAIResponseParserTest.java b/src/test/java/org/xcore/plugin/localization/OpenAIResponseParserTest.java index ad2bfa3b..a2322553 100644 --- a/src/test/java/org/xcore/plugin/localization/OpenAIResponseParserTest.java +++ b/src/test/java/org/xcore/plugin/localization/OpenAIResponseParserTest.java @@ -3,7 +3,7 @@ import com.google.gson.JsonObject; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationSafetyService; import static org.assertj.core.api.Assertions.assertThat; @@ -39,7 +39,7 @@ void extractTranslation_returnsResponsesOutputText() { } private TranslationSafetyService translationSafetyService(boolean structuredOutputRequired) { - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); config.translation.llm.structuredOutputRequired = structuredOutputRequired; return new TranslationSafetyService(config); } diff --git a/src/test/java/org/xcore/plugin/localization/OpenAITranslationProviderTest.java b/src/test/java/org/xcore/plugin/localization/OpenAITranslationProviderTest.java index c818d2b5..1f2faaa5 100644 --- a/src/test/java/org/xcore/plugin/localization/OpenAITranslationProviderTest.java +++ b/src/test/java/org/xcore/plugin/localization/OpenAITranslationProviderTest.java @@ -7,8 +7,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationSafetyService; import java.io.IOException; @@ -168,7 +168,7 @@ private TranslationResult translate(OpenAITranslationProvider provider, Translat } private TranslationSafetyService translationSafetyService(boolean structuredOutputRequired) { - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); config.translation.llm.structuredOutputRequired = structuredOutputRequired; return new TranslationSafetyService(config); } diff --git a/src/test/java/org/xcore/plugin/localization/TranslationProviderFactoryTest.java b/src/test/java/org/xcore/plugin/localization/TranslationProviderFactoryTest.java new file mode 100644 index 00000000..57b386e6 --- /dev/null +++ b/src/test/java/org/xcore/plugin/localization/TranslationProviderFactoryTest.java @@ -0,0 +1,99 @@ +package org.xcore.plugin.localization; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; +import org.xcore.plugin.service.TranslationSafetyService; + +import java.util.LinkedHashMap; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TranslationProviderFactoryTest { + + private TranslationExecutor translationExecutor; + + @AfterEach + void tearDown() { + if (translationExecutor != null) { + translationExecutor.shutdown(); + } + } + + @Test + @DisplayName("translationProviderPipeline returns empty pipeline when translation is disabled") + void translationProviderPipeline_returnsEmptyPipeline_whenTranslationDisabled() { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.translation.enabled = false; + + TranslationProviderPipeline pipeline = new TranslationProviderFactory(config, new GlobalConfig()) + .translationProviderPipeline( + googleTranslationProvider(), + translationSafetyService(), + translationExecutor() + ); + + assertThat(pipeline.providers()).isEmpty(); + } + + @Test + @DisplayName("translationProviderPipeline preserves configured order and filters missing or disabled providers") + void translationProviderPipeline_preservesOrder_andFiltersMissingOrDisabledProviders() { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.translation.pipeline = List.of("openai-main", "missing", "google", "openai-disabled"); + + GlobalConfig globalConfig = new GlobalConfig(); + globalConfig.translationProviders = new LinkedHashMap<>(); + globalConfig.translationProviders.put("openai-main", openAiProviderConfig(true)); + globalConfig.translationProviders.put("google", googleProviderConfig(true)); + globalConfig.translationProviders.put("openai-disabled", openAiProviderConfig(false)); + + TranslationProviderPipeline pipeline = new TranslationProviderFactory(config, globalConfig) + .translationProviderPipeline( + googleTranslationProvider(), + translationSafetyService(), + translationExecutor() + ); + + assertThat(pipeline.providers()) + .hasSize(2) + .extracting(provider -> provider.name() + ":" + provider.type()) + .containsExactly("openai-main:openai", "google:google"); + } + + private GoogleTranslationProvider googleTranslationProvider() { + return new GoogleTranslationProvider(translationExecutor()); + } + + private TranslationSafetyService translationSafetyService() { + return new TranslationSafetyService(new TomlXcoreConfig()); + } + + private TranslationExecutor translationExecutor() { + if (translationExecutor == null) { + translationExecutor = new TranslationExecutor(); + } + return translationExecutor; + } + + private GlobalConfig.TranslationProviderConfig openAiProviderConfig(boolean enabled) { + GlobalConfig.TranslationProviderConfig providerConfig = new GlobalConfig.TranslationProviderConfig(); + providerConfig.type = "openai"; + providerConfig.enabled = enabled; + providerConfig.apiKey = "test-key"; + providerConfig.model = "gpt-test"; + providerConfig.normalize(); + return providerConfig; + } + + private GlobalConfig.TranslationProviderConfig googleProviderConfig(boolean enabled) { + GlobalConfig.TranslationProviderConfig providerConfig = new GlobalConfig.TranslationProviderConfig(); + providerConfig.type = "google"; + providerConfig.enabled = enabled; + providerConfig.normalize(); + return providerConfig; + } +} diff --git a/src/test/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheckTest.java b/src/test/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheckTest.java index 3e0805f6..4ac59ca2 100644 --- a/src/test/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheckTest.java +++ b/src/test/java/org/xcore/plugin/security/ingress/checks/PlayerLimitCheckTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.security.ingress.AccessResult; import static org.assertj.core.api.Assertions.assertThat; @@ -21,7 +21,7 @@ class PlayerLimitCheckTest { private IngressChecksTestSupport.VarsState varsState; private Administration admins; - private Config config; + private TomlXcoreConfig config; private PlayerLimitCheck check; @BeforeEach @@ -34,7 +34,7 @@ void setUp() { Vars.netServer = netServer; Groups.player = IngressChecksTestSupport.newPlayerGroup(); - config = new Config(); + config = new TomlXcoreConfig(); check = new PlayerLimitCheck(config); } @@ -46,7 +46,7 @@ void tearDown() { @Test @DisplayName("shouldAllow_whenPlayerLimitIsDisabled") void shouldAllow_whenPlayerLimitIsDisabled() { - config.playerLimit = 0; + config.server.playerLimit = 0; var packet = IngressChecksTestSupport.newPacket(); var result = check.check(new IngressChecksTestSupport.DummyConnection("1.1.1.1"), packet); @@ -57,7 +57,7 @@ void shouldAllow_whenPlayerLimitIsDisabled() { @Test @DisplayName("shouldAllow_whenPlayerIsAdmin") void shouldAllow_whenPlayerIsAdmin() { - config.playerLimit = 1; + config.server.playerLimit = 1; var packet = IngressChecksTestSupport.newPacket(); when(admins.isAdmin(packet.uuid, packet.usid)).thenReturn(true); Groups.player.add(IngressChecksTestSupport.createPlayer("A", "uuid-a", "usid-a", false)); @@ -71,7 +71,7 @@ void shouldAllow_whenPlayerIsAdmin() { @Test @DisplayName("shouldDenyPlayerLimit_whenPlayerCountExceedsNoAdminLimit") void shouldDenyPlayerLimit_whenPlayerCountExceedsNoAdminLimit() { - config.playerLimit = 2; + config.server.playerLimit = 2; var packet = IngressChecksTestSupport.newPacket(); when(admins.isAdmin(packet.uuid, packet.usid)).thenReturn(false); Groups.player.add(IngressChecksTestSupport.createPlayer("A", "uuid-a", "usid-a", false)); @@ -86,7 +86,7 @@ void shouldDenyPlayerLimit_whenPlayerCountExceedsNoAdminLimit() { @Test @DisplayName("shouldAllow_whenPlayerCountIsWithinLimit") void shouldAllow_whenPlayerCountIsWithinLimit() { - config.playerLimit = 2; + config.server.playerLimit = 2; var packet = IngressChecksTestSupport.newPacket(); when(admins.isAdmin(packet.uuid, packet.usid)).thenReturn(false); Groups.player.add(IngressChecksTestSupport.createPlayer("A", "uuid-a", "usid-a", false)); diff --git a/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationServiceTest.java b/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationServiceTest.java index ccc3028f..7ff2b9b3 100644 --- a/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationServiceTest.java +++ b/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationOrchestrationServiceTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; @@ -12,7 +12,7 @@ class IpReputationOrchestrationServiceTest { @Test @DisplayName("isBlocked returns false when feature is disabled") void isBlocked_featureDisabled_returnsFalse() { - Config config = configWithEnabled(false); + TomlXcoreConfig config = configWithEnabled(false); var service = newService(config, mockAllowlist(), mockCache(), mockProvider(), mockPolicy()); assertThat(service.isBlocked("1.2.3.4")).isFalse(); @@ -21,7 +21,7 @@ void isBlocked_featureDisabled_returnsFalse() { @Test @DisplayName("isBlocked returns false for blank ip") void isBlocked_blankIp_returnsFalse() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); var service = newService(config, mockAllowlist(), mockCache(), mockProvider(), mockPolicy()); assertThat(service.isBlocked("")).isFalse(); @@ -31,7 +31,7 @@ void isBlocked_blankIp_returnsFalse() { @Test @DisplayName("isBlocked returns false when ip is on allowlist") void isBlocked_allowlisted_returnsFalse() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationAllowlist allowlist = mockAllowlist(); when(allowlist.contains("1.2.3.4")).thenReturn(true); @@ -43,7 +43,7 @@ void isBlocked_allowlisted_returnsFalse() { @Test @DisplayName("isBlocked uses cache hit when available") void isBlocked_cacheHit_usesCache() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationCache cache = mockCache(); IpReputationResult cached = new IpReputationResult("1.2.3.4", true, false, false); when(cache.get("1.2.3.4")).thenReturn(cached); @@ -61,7 +61,7 @@ void isBlocked_cacheHit_usesCache() { @Test @DisplayName("isBlocked consults provider on cache miss and caches result") void isBlocked_cacheMiss_consultsProviderAndCaches() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationCache cache = mockCache(); when(cache.get("1.2.3.4")).thenReturn(null); @@ -81,7 +81,7 @@ void isBlocked_cacheMiss_consultsProviderAndCaches() { @Test @DisplayName("isBlocked fails open when provider returns null") void isBlocked_providerNull_failsOpen() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationCache cache = mockCache(); when(cache.get("1.2.3.4")).thenReturn(null); @@ -96,7 +96,7 @@ void isBlocked_providerNull_failsOpen() { @Test @DisplayName("isBlocked fails open when allowlist throws") void isBlocked_allowlistThrows_failsOpen() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationAllowlist allowlist = mockAllowlist(); when(allowlist.contains("1.2.3.4")).thenThrow(new RuntimeException("redis down")); @@ -113,7 +113,7 @@ void isBlocked_allowlistThrows_failsOpen() { @Test @DisplayName("isBlocked fails open when cache throws") void isBlocked_cacheThrows_failsOpen() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationCache cache = mockCache(); when(cache.get("1.2.3.4")).thenThrow(new RuntimeException("redis down")); @@ -132,7 +132,7 @@ void isBlocked_cacheThrows_failsOpen() { @Test @DisplayName("isBlocked fails open when provider throws") void isBlocked_providerThrows_failsOpen() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationCache cache = mockCache(); when(cache.get("1.2.3.4")).thenReturn(null); @@ -147,7 +147,7 @@ void isBlocked_providerThrows_failsOpen() { @Test @DisplayName("isBlocked fails open when cache put throws") void isBlocked_cachePutThrows_stillEvaluatesPolicy() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationCache cache = mockCache(); when(cache.get("1.2.3.4")).thenReturn(null); when(cache.put(anyString(), any())).thenThrow(new RuntimeException("redis down")); @@ -167,7 +167,7 @@ void isBlocked_cachePutThrows_stillEvaluatesPolicy() { @Test @DisplayName("lookup returns null when feature is disabled") void lookup_featureDisabled_returnsNull() { - Config config = configWithEnabled(false); + TomlXcoreConfig config = configWithEnabled(false); var service = newService(config, mockAllowlist(), mockCache(), mockProvider(), mockPolicy()); assertThat(service.lookup("1.2.3.4")).isNull(); @@ -176,7 +176,7 @@ void lookup_featureDisabled_returnsNull() { @Test @DisplayName("lookup returns provider result when enabled") void lookup_featureEnabled_returnsProviderResult() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationProvider provider = mockProvider(); IpReputationResult result = new IpReputationResult("1.2.3.4", true, false, false); when(provider.lookup("1.2.3.4")).thenReturn(result); @@ -189,7 +189,7 @@ void lookup_featureEnabled_returnsProviderResult() { @Test @DisplayName("lookup fails open when provider throws") void lookup_providerThrows_failsOpen() { - Config config = configWithEnabled(true); + TomlXcoreConfig config = configWithEnabled(true); IpReputationProvider provider = mockProvider(); when(provider.lookup("1.2.3.4")).thenThrow(new RuntimeException("timeout")); @@ -199,7 +199,7 @@ void lookup_providerThrows_failsOpen() { } private static IpReputationOrchestrationService newService( - Config config, + TomlXcoreConfig config, IpReputationAllowlist allowlist, IpReputationCache cache, IpReputationProvider provider, @@ -207,8 +207,8 @@ private static IpReputationOrchestrationService newService( return new IpReputationOrchestrationService(config, allowlist, cache, provider, policy); } - private static Config configWithEnabled(boolean enabled) { - Config config = new Config(); + private static TomlXcoreConfig configWithEnabled(boolean enabled) { + TomlXcoreConfig config = new TomlXcoreConfig(); config.ipReputation.enabled = enabled; return config; } diff --git a/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicyTest.java b/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicyTest.java index ac8e5064..3c26a472 100644 --- a/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicyTest.java +++ b/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpReputationPolicyTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import static org.assertj.core.api.Assertions.assertThat; @@ -11,7 +11,7 @@ class IpReputationPolicyTest { @Test @DisplayName("returns false for null result") void returnsFalse_forNullResult() { - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); assertThat(new IpReputationPolicy(config).isBlocked(null)).isFalse(); } @@ -19,7 +19,7 @@ void returnsFalse_forNullResult() { @Test @DisplayName("blockProxy honors proxy signal independently") void blockProxy_honorsProxySignalIndependently() { - Config config = baseConfig(); + TomlXcoreConfig config = baseConfig(); config.ipReputation.blockProxy = true; config.ipReputation.blockVpn = false; config.ipReputation.blockTor = false; @@ -30,7 +30,7 @@ void blockProxy_honorsProxySignalIndependently() { @Test @DisplayName("blockVpn honors combined provider proxy signal independently") void blockVpn_honorsCombinedProviderSignalIndependently() { - Config config = baseConfig(); + TomlXcoreConfig config = baseConfig(); config.ipReputation.blockProxy = false; config.ipReputation.blockVpn = true; config.ipReputation.blockTor = false; @@ -41,7 +41,7 @@ void blockVpn_honorsCombinedProviderSignalIndependently() { @Test @DisplayName("blockTor honors combined provider proxy signal independently") void blockTor_honorsCombinedProviderSignalIndependently() { - Config config = baseConfig(); + TomlXcoreConfig config = baseConfig(); config.ipReputation.blockProxy = false; config.ipReputation.blockVpn = false; config.ipReputation.blockTor = true; @@ -52,7 +52,7 @@ void blockTor_honorsCombinedProviderSignalIndependently() { @Test @DisplayName("blockHosting honors hosting signal") void blockHosting_honorsHostingSignal() { - Config config = baseConfig(); + TomlXcoreConfig config = baseConfig(); config.ipReputation.blockHosting = true; IpReputationResult result = new IpReputationResult("1.2.3.4", false, true, false); @@ -63,13 +63,13 @@ void blockHosting_honorsHostingSignal() { @Test @DisplayName("returns false when all toggles are disabled") void returnsFalse_whenAllTogglesDisabled() { - Config config = baseConfig(); + TomlXcoreConfig config = baseConfig(); assertThat(new IpReputationPolicy(config).isBlocked(proxyResult())).isFalse(); } - private static Config baseConfig() { - Config config = new Config(); + private static TomlXcoreConfig baseConfig() { + TomlXcoreConfig config = new TomlXcoreConfig(); config.ipReputation.blockProxy = false; config.ipReputation.blockVpn = false; config.ipReputation.blockTor = false; diff --git a/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java b/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java index fe523df5..d1271e15 100644 --- a/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/DiscordLinkServiceTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.network.RedisDiscordLinkCodeStore; @@ -32,8 +32,8 @@ void createCode_invalidatesOldCodesAndPublishesCreationEvent() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; when(codeStore.store(any())).thenReturn(true); @@ -59,8 +59,8 @@ void createCode_returnsErrorWhenCodePersistenceFails() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; when(codeStore.store(any())).thenReturn(false); @@ -83,8 +83,8 @@ void createCode_returnsAlreadyLinkedWhenPlayerAlreadyHasDiscordAccount() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; DiscordLinkService service = new DiscordLinkService(codeStore, playerDataRepository, sessionService, networkService, config, discordAdminAccessService); @@ -111,8 +111,8 @@ void getOrCreateActiveCode_returnsExistingActiveCodeWithoutCreatingNewOne() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; DiscordLinkService service = new DiscordLinkService(codeStore, playerDataRepository, sessionService, networkService, config, discordAdminAccessService); @@ -141,8 +141,8 @@ void getOrCreateActiveCode_clearsExpiredCodeBeforeCreatingNewOne() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; when(codeStore.store(any())).thenReturn(true); @@ -173,8 +173,8 @@ void confirmLink_allowsSameDiscordAccountAcrossPlayers() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; DiscordLinkService service = new DiscordLinkService(codeStore, playerDataRepository, sessionService, networkService, config, discordAdminAccessService); @@ -210,8 +210,8 @@ void confirmLink_rejectsOtherDiscordAccountForAlreadyLinkedPlayer() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; DiscordLinkService service = new DiscordLinkService(codeStore, playerDataRepository, sessionService, networkService, config, discordAdminAccessService); @@ -243,8 +243,8 @@ void unlinkByUuid_updatesOfflinePlayerData() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; DiscordLinkService service = new DiscordLinkService(codeStore, playerDataRepository, sessionService, networkService, config, discordAdminAccessService); @@ -277,8 +277,8 @@ void unlinkByUuid_clearsOnlineSessionDiscordState() { SessionService sessionService = mock(SessionService.class); NetworkService networkService = mock(NetworkService.class); DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; DiscordLinkService service = new DiscordLinkService(codeStore, playerDataRepository, sessionService, networkService, config, discordAdminAccessService); diff --git a/src/test/java/org/xcore/plugin/service/GameDataServiceTest.java b/src/test/java/org/xcore/plugin/service/GameDataServiceTest.java index 10e861ba..496b92bf 100644 --- a/src/test/java/org/xcore/plugin/service/GameDataServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/GameDataServiceTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.GameDataRepository; import org.xcore.plugin.model.GameData; import org.xcore.plugin.model.MapData; @@ -50,8 +50,7 @@ void tearDown() { @DisplayName("finishGame marks players on winning team before saving") void finishGameMarksWinningPlayersBeforeSaving() { var repository = mock(GameDataRepository.class); - var config = new Config(); - config.server = "mini-pvp"; + var config = config("mini-pvp"); var service = new GameDataService(config, repository); var map = new MapData("Map", "map.msav", "Author", "pvp"); state.rules.pvp = true; @@ -83,8 +82,7 @@ void finishGameMarksWinningPlayersBeforeSaving() { @DisplayName("finishGame does not mark winners for derelict team") void finishGameDoesNotMarkWinnersForDerelictTeam() { var repository = mock(GameDataRepository.class); - var config = new Config(); - config.server = "mini-pvp"; + var config = config("mini-pvp"); var service = new GameDataService(config, repository); var map = new MapData("Map", "map.msav", "Author", "pvp"); state.rules.pvp = true; @@ -111,7 +109,7 @@ void finishGameDoesNotMarkWinnersForDerelictTeam() { @Test @DisplayName("markWinners only marks players whose final team matches winner") void markWinnersMatchesFinalTeam() { - var service = new GameDataService(new Config(), mock(GameDataRepository.class)); + var service = new GameDataService(config("server"), mock(GameDataRepository.class)); var map = new MapData("Map", "map.msav", "Author", "pvp"); service.startNewGame(map, "pvp", null); @@ -133,7 +131,7 @@ void markWinnersMatchesFinalTeam() { @DisplayName("finishGame stores wave metadata for survival defeat") void finishGameStoresWaveMetadataForSurvivalDefeat() { var repository = mock(GameDataRepository.class); - var service = new GameDataService(new Config(), repository); + var service = new GameDataService(config("server"), repository); var map = new MapData("Map", "map.msav", "Author", "survival"); state.wave = 42; @@ -158,8 +156,7 @@ void finishGameStoresWaveMetadataForSurvivalDefeat() { @DisplayName("finishGame stores non-natural finish as not counted in stats") void finishGameStoresNonNaturalFinishAsNotCounted() { var repository = mock(GameDataRepository.class); - var config = new Config(); - config.server = "mini-pvp"; + var config = config("mini-pvp"); var service = new GameDataService(config, repository); var map = new MapData("Map", "map.msav", "Author", "pvp"); @@ -178,7 +175,7 @@ void finishGameStoresNonNaturalFinishAsNotCounted() { @Test @DisplayName("applyPlacements stores placement on matching players") void applyPlacementsStoresPlacement() { - var service = new GameDataService(new Config(), mock(GameDataRepository.class)); + var service = new GameDataService(config("server"), mock(GameDataRepository.class)); var map = new MapData("Map", "map.msav", "Author", "hexed"); service.startNewGame(map, "hexed", null); @@ -198,6 +195,19 @@ void applyPlacementsStoresPlacement() { assertThat(second.getPlacement()).isEqualTo(2); } + @Test + @DisplayName("startNewGame classifies mini-hexed server as HEXED") + void startNewGameClassifiesMiniHexedServerAsHexed() { + var service = new GameDataService(config("mini-hexed"), mock(GameDataRepository.class)); + var map = new MapData("Map", "map.msav", "Author", "pvp"); + + state.rules.pvp = true; + service.startNewGame(map, "pvp", null); + + assertThat(service.getCurrent().getStatsCategory()).isEqualTo(GameStatsCategory.HEXED); + assertThat(service.getCurrent().isRanked()).isTrue(); + } + private static PlayerGameStats playerStats(String uuid, String finalTeam) { return PlayerGameStats.builder() .uuid(uuid) @@ -206,4 +216,10 @@ private static PlayerGameStats playerStats(String uuid, String finalTeam) { .finalTeam(finalTeam) .build(); } + + private static TomlXcoreConfig config(String serverName) { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = serverName; + return config; + } } diff --git a/src/test/java/org/xcore/plugin/service/MapServiceTest.java b/src/test/java/org/xcore/plugin/service/MapServiceTest.java index 78c96190..7019cd86 100644 --- a/src/test/java/org/xcore/plugin/service/MapServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/MapServiceTest.java @@ -11,8 +11,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.EventData; @@ -67,8 +67,8 @@ void resolveNextMapReturnsActiveEventMap() { when(eventDataRepository.findActive()).thenReturn(Optional.of(eventData)); when(mapDataRepository.findById(eventData.map)).thenReturn(eventMapData); - Config config = new Config(); - config.server = "event"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "event"; MapService service = new MapService( eventDataRepository, @@ -100,8 +100,8 @@ void resolveNextMapFallsBackToRotation() { Map next = mock(Map.class); when(maps.getNextMap(Gamemode.pvp, previous)).thenReturn(next); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; MapService service = new MapService( mock(EventDataRepository.class), @@ -150,7 +150,7 @@ void findPersistedMapPrefersExactPersistedIdentity() { mock(EventDataRepository.class), mock(MapDataRepository.class), mock(SessionService.class), - new Config(), + new TomlXcoreConfig(), new GlobalConfig(), mock(VoteService.class), mock(VoteNewWaveFactory.class), diff --git a/src/test/java/org/xcore/plugin/service/MapServiceVoteNewWaveTest.java b/src/test/java/org/xcore/plugin/service/MapServiceVoteNewWaveTest.java index c437c0d6..27029811 100644 --- a/src/test/java/org/xcore/plugin/service/MapServiceVoteNewWaveTest.java +++ b/src/test/java/org/xcore/plugin/service/MapServiceVoteNewWaveTest.java @@ -10,8 +10,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.localization.Localization; @@ -66,7 +66,7 @@ void startNewWaveSessionBlocksWhenWavesDisabled() { mock(EventDataRepository.class), mock(MapDataRepository.class), sessionService, - new Config(), + new TomlXcoreConfig(), new GlobalConfig(), mock(VoteService.class), mock(VoteNewWaveFactory.class), @@ -92,8 +92,8 @@ void startNewWaveSessionBlocksWhenFeatureDisabled() { when(sessionService.get("player-1")).thenReturn(session); when(session.locale()).thenReturn(localization); - Config config = new Config(); - config.disabledFeatures.add(Feature.VNW.key()); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.runtime.disabledFeatures.add(Feature.VNW.key()); MapService service = new MapService( mock(EventDataRepository.class), @@ -132,7 +132,7 @@ void forcedStartNewWaveSessionSkipsImmediately() { mock(EventDataRepository.class), mock(MapDataRepository.class), sessionService, - new Config(), + new TomlXcoreConfig(), new GlobalConfig(), voteService, mock(VoteNewWaveFactory.class), @@ -167,7 +167,7 @@ void startNewWaveSessionCreatesVoteWhenAllowed() { mock(EventDataRepository.class), mock(MapDataRepository.class), sessionService, - new Config(), + new TomlXcoreConfig(), new GlobalConfig(), voteService, voteFactory, diff --git a/src/test/java/org/xcore/plugin/service/PlayerDisplayServiceTest.java b/src/test/java/org/xcore/plugin/service/PlayerDisplayServiceTest.java index e330037d..6b8cff78 100644 --- a/src/test/java/org/xcore/plugin/service/PlayerDisplayServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/PlayerDisplayServiceTest.java @@ -6,7 +6,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.gamemode.hexed.HexedRanks; import org.xcore.plugin.model.PlayerData; @@ -182,9 +182,9 @@ private static PlayerData basePlayer() { return data; } - private static Config config(String server) { - var config = new Config(); - config.server = server; + private static TomlXcoreConfig config(String server) { + var config = new TomlXcoreConfig(); + config.server.name = server; return config; } } diff --git a/src/test/java/org/xcore/plugin/service/PlayerProfileSettingsServiceTest.java b/src/test/java/org/xcore/plugin/service/PlayerProfileSettingsServiceTest.java index 5635d826..baa386ad 100644 --- a/src/test/java/org/xcore/plugin/service/PlayerProfileSettingsServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/PlayerProfileSettingsServiceTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.player.Badge; @@ -103,8 +103,8 @@ void updateCustomNickname_onlineSession_mutatesAndRefreshesAndSyncs() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -132,8 +132,8 @@ void updateCustomNickname_offlineTarget_mutatesAndSyncsWithoutRefresh() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -158,8 +158,8 @@ void updateCustomNickname_onlineSession_noSync_noNetworkPost() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -182,7 +182,7 @@ void updateLanguage_onlineSession_delegatesToSessionHelper() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -206,7 +206,7 @@ void updateLanguage_offlineTarget_mutatesAndCallsRepository() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -229,7 +229,7 @@ void updateDescription_onlineSession_mutatesAndCallsRepository() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -252,7 +252,7 @@ void updateDescription_offlineTarget_mutatesAndCallsRepository() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -273,7 +273,7 @@ void updateLeaderboard_onlineSession_mutatesAndCallsRepository() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); + TomlXcoreConfig config = new TomlXcoreConfig(); PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -297,8 +297,8 @@ void updateActiveBadge_onlineSession_mutatesAndRefreshesAndSyncs() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -323,8 +323,8 @@ void updateBadgeSymbolColorMode_offlineTarget_mutatesAndCallsRepository() { PlayerDataRepository repository = mock(PlayerDataRepository.class); PlayerDisplayService displayService = mock(PlayerDisplayService.class); NetworkService network = mock(NetworkService.class); - Config config = new Config(); - config.server = "mini-pvp"; + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; PlayerProfileSettingsService service = new PlayerProfileSettingsService( sessionService, repository, displayService, network, config); @@ -346,6 +346,6 @@ private PlayerProfileSettingsService service() { mock(PlayerDataRepository.class), mock(PlayerDisplayService.class), mock(NetworkService.class), - new Config()); + new TomlXcoreConfig()); } } diff --git a/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java b/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java index 0133c650..e12471bf 100644 --- a/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test; import org.xcore.protocol.generated.messages.chat.ChatMessages.ChatPrivateV1; import org.xcore.plugin.config.GlobalConfig; -import org.xcore.plugin.config.Config; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PrivateMessageRepository; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; @@ -76,9 +76,11 @@ void send_savesAndUpdatesReplyState_forValidPid() { assertThat(result).isTrue(); assertThat(sender.lastPrivateTargetPid).isEqualTo(42); assertThat(sender.lastPrivateMessageAt).isGreaterThan(0L); + var transportEvent = org.mockito.ArgumentCaptor.forClass(ChatPrivateV1.class); verify(privateMessageRepository).save(any(PrivateMessage.class)); verify(sender.locale()).send(eq("private-message-sent"), anyMap()); - verify(networkService).post(any(ChatPrivateV1.class)); + verify(networkService).post(transportEvent.capture()); + assertThat(transportEvent.getValue().server()).isEqualTo("mini-pvp"); } @Test @@ -272,10 +274,16 @@ private static Session mockSession(String uuid, int pid, String nickname) { return session; } + private static TomlXcoreConfig config(String serverName) { + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = serverName; + return config; + } + private static final class PrivateMessageModule implements AvajeModule { @Override public Class[] classes() { - return new Class[]{PrivateMessageService.class, GlobalConfig.class}; + return new Class[]{PrivateMessageService.class, GlobalConfig.class, TomlXcoreConfig.class}; } @Override @@ -283,8 +291,8 @@ public void build(Builder builder) { if (builder.isBeanAbsent(GlobalConfig.class)) { builder.register(new GlobalConfig()); } - if (builder.isBeanAbsent(Config.class)) { - builder.register(new Config()); + if (builder.isBeanAbsent(TomlXcoreConfig.class)) { + builder.register(config("mini-pvp")); } if (builder.isBeanAbsent(PrivateMessageService.class)) { builder.register(new PrivateMessageService( @@ -292,7 +300,7 @@ public void build(Builder builder) { builder.get(SessionService.class), builder.get(SecurityService.class), builder.get(NetworkService.class), - builder.get(Config.class), + builder.get(TomlXcoreConfig.class), builder.get(GlobalConfig.class) )); } diff --git a/src/test/java/org/xcore/plugin/service/ServerDiscoveryServiceTest.java b/src/test/java/org/xcore/plugin/service/ServerDiscoveryServiceTest.java new file mode 100644 index 00000000..df5e4735 --- /dev/null +++ b/src/test/java/org/xcore/plugin/service/ServerDiscoveryServiceTest.java @@ -0,0 +1,205 @@ +package org.xcore.plugin.service; + +import arc.Core; +import arc.Settings; +import arc.util.Time; +import mindustry.Vars; +import mindustry.core.GameState; +import mindustry.core.Version; +import mindustry.entities.EntityGroup; +import mindustry.game.Gamemode; +import mindustry.game.Rules; +import mindustry.gen.Groups; +import mindustry.gen.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.xcore.plugin.common.PluginState; +import org.xcore.plugin.config.TomlXcoreConfig; + +import java.nio.ByteBuffer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class ServerDiscoveryServiceTest { + + private GameState previousState; + private EntityGroup previousPlayers; + private Settings previousSettings; + private String configuredServerName; + private String configuredDescription; + + @BeforeEach + void setUp() { + previousState = Vars.state; + previousPlayers = Groups.player; + previousSettings = Core.settings; + + Vars.state = new GameState(); + Vars.state.rules = mock(Rules.class); + Vars.state.map = mock(mindustry.maps.Map.class); + when(Vars.state.map.name()).thenReturn("Test Map"); + when(Vars.state.rules.mode()).thenReturn(Gamemode.pvp); + Vars.state.rules.modeName = "Ranked PvP"; + Vars.state.wave = 17; + + Groups.player = new EntityGroup<>(Player.class, false, false); + Core.settings = mock(Settings.class); + configuredServerName = "Mini PvP"; + configuredDescription = "Server description"; + when(Core.settings.getString(anyString(), anyString())).thenAnswer(invocation -> switch (invocation.getArgument(0, String.class)) { + case "servername" -> configuredServerName; + case "desc" -> configuredDescription; + default -> invocation.getArgument(1, String.class); + }); + when(Core.settings.getInt("totalPlayers", 0)).thenReturn(5); + } + + @AfterEach + void tearDown() { + Vars.state = previousState; + Groups.player = previousPlayers; + Core.settings = previousSettings; + } + + @Test + @DisplayName("updateFooter uses game started timer setting") + void updateFooterUsesGameStartedTimerSetting() { + var pluginState = new PluginState(); + pluginState.gameStartTime = 60_000L; + + try (MockedStatic