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/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/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/cloud/config/CloudGuardConfigurer.java b/src/main/java/org/xcore/plugin/cloud/config/CloudGuardConfigurer.java index 88ee4859..86e9570d 100644 --- a/src/main/java/org/xcore/plugin/cloud/config/CloudGuardConfigurer.java +++ b/src/main/java/org/xcore/plugin/cloud/config/CloudGuardConfigurer.java @@ -9,7 +9,7 @@ import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.cloud.annotation.PlayTimeLimit; import org.xcore.plugin.cloud.exception.XCoreCommandException; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.service.SecurityService; @@ -22,17 +22,17 @@ public class CloudGuardConfigurer { private final Provider securityService; private final Provider sessionService; - private final GlobalConfig globalConfig; + private final TomlSecretsConfig secretsConfig; private final DisabledCommandPolicy disabledCommandPolicy; @Inject public CloudGuardConfigurer(Provider securityService, Provider sessionService, - GlobalConfig globalConfig, + TomlSecretsConfig secretsConfig, DisabledCommandPolicy disabledCommandPolicy) { this.securityService = securityService; this.sessionService = sessionService; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.disabledCommandPolicy = disabledCommandPolicy; } @@ -74,8 +74,8 @@ public void configure(MindustryCommandManager manager, } int requiredMinutes = switch (playTimeLimit) { - case GLOBAL_CHAT -> globalConfig.minPlayTimeForGlobalChat; - case VOTE_KICK -> globalConfig.minPlayTimeForVotekick; + case GLOBAL_CHAT -> secretsConfig.chat.global.minPlayTimeMinutes; + case VOTE_KICK -> secretsConfig.moderation.votekick.minPlayTimeMinutes; case CUSTOM -> 0; }; 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..a65f83f4 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.TomlSecretsConfig; +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 GlobalConfig globalConfig; + private final TomlXcoreConfig config; + private final TomlSecretsConfig secretsConfig; private final TranslatorLanguagesProvider translatorLanguagesProvider; - private final ChatFormatService chatFormatService; private final TranslatorService translatorService; private final DiscordLinkService discordLinkService; private final DiscordMenu discordMenu; @@ -45,9 +44,9 @@ public class SocialController implements CloudClientController { @Inject public SocialController(SessionService sessionService, NetworkService network, - Config config, - GlobalConfig globalConfig, - TranslatorLanguagesProvider translatorLanguagesProvider, + TomlXcoreConfig config, + TomlSecretsConfig secretsConfig, + TranslatorLanguagesProvider translatorLanguagesProvider, ChatFormatService chatFormatService, TranslatorService translatorService, DiscordLinkService discordLinkService, @@ -55,9 +54,8 @@ public SocialController(SessionService sessionService, this.sessionService = sessionService; this.network = network; this.config = config; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; 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" )); } @@ -114,7 +112,7 @@ public void discordLink(XCoreSender sender) { local.send("commands-discord-link-created", args( "code", result.code(), "expireMinutes", result.remainingMinutes(System.currentTimeMillis()), - "discordUrl", globalConfig.discordUrl + "discordUrl", secretsConfig.externalLinks.discordUrl )); } 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 d6019f78..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 @@ -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; @@ -15,7 +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; @@ -23,61 +25,85 @@ @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 TomlXcoreConfig 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, + TomlXcoreConfig 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, + TomlXcoreConfig 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); + TomlXcoreConfig 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)); - Log.info("Config field '@' updated to '@'.", field, value); + try { + tomlStore.write(updated); + } catch (Exception e) { + Log.err("Failed to persist config change for field '@': @", field, e.getMessage()); + return; + } + + applyUpdatedConfig(updated); + Log.info("Config field '@' updated.", field); } @Command("edit-data ") @@ -109,6 +135,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, @@ -130,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 75e104b8..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 @@ -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; @@ -18,7 +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, - @Named("xcConfigFile") Fi configFile, + 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, configFile, prettyGson); + 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, - Fi configFile, + TomlXcoreConfig serverLocalConfig, + ServerLocalConfigTomlStore tomlStore, Gson prettyGson) { - this(network, playerDataRepository, pluginState, sessionService, mapIdentityAuditService, null, config, configFile, 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 eab30ad7..8fb048e5 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 org.xcore.plugin.config.TomlXcoreConfig; import java.util.HashSet; import java.util.Locale; @@ -18,14 +17,12 @@ enum ToggleTarget { record ToggleMutationResult(boolean changed, String value) { } - private final Config config; - private final Fi configFile; - private final Gson prettyGson; + private final TomlXcoreConfig config; + private final ServerLocalConfigTomlStore tomlStore; - RuntimeToggleConfigService(Config config, Fi configFile, Gson prettyGson) { + RuntimeToggleConfigService(TomlXcoreConfig config, ServerLocalConfigTomlStore tomlStore) { this.config = config; - this.configFile = configFile; - this.prettyGson = prettyGson; + this.tomlStore = tomlStore; } ToggleMutationResult disable(ToggleTarget target, String value) { @@ -34,7 +31,12 @@ ToggleMutationResult disable(ToggleTarget target, String value) { return new ToggleMutationResult(false, value); } - save(); + try { + save(); + } catch (RuntimeException e) { + values.remove(value); + throw e; + } return new ToggleMutationResult(true, value); } @@ -44,7 +46,12 @@ ToggleMutationResult enable(ToggleTarget target, String value) { return new ToggleMutationResult(false, value); } - save(); + try { + save(); + } catch (RuntimeException e) { + values.add(value); + throw e; + } return new ToggleMutationResult(true, value); } @@ -80,8 +87,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; }; } @@ -94,14 +101,14 @@ 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; } private void save() { - configFile.writeString(prettyGson.toJson(config)); + tomlStore.write(config); } } 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..9c82dab4 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.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationMetricsService; import java.util.Locale; @@ -17,23 +17,23 @@ @Singleton public class TranslationStatsController implements CloudServerController { - private final Config config; - private final GlobalConfig globalConfig; + private final TomlXcoreConfig config; + private final TomlSecretsConfig secretsConfig; private final TranslationMetricsService translationMetricsService; @Inject - public TranslationStatsController(Config config, - GlobalConfig globalConfig, + public TranslationStatsController(TomlXcoreConfig config, + TomlSecretsConfig secretsConfig, TranslationMetricsService translationMetricsService) { this.config = config; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.translationMetricsService = translationMetricsService; } @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)); @@ -65,7 +65,7 @@ public void translationStats(XCoreSender sender) { continue; } - GlobalConfig.TranslationProviderConfig providerConfig = globalConfig.translationProviders.get(providerId); + TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig = secretsConfig.translation.providers.get(providerId); if (providerConfig == null) { Log.info(" - @ (missing config)", providerId); continue; diff --git a/src/main/java/org/xcore/plugin/config/ConfigFactory.java b/src/main/java/org/xcore/plugin/config/ConfigFactory.java index 5cbb1c6f..e6128f38 100644 --- a/src/main/java/org/xcore/plugin/config/ConfigFactory.java +++ b/src/main/java/org/xcore/plugin/config/ConfigFactory.java @@ -5,51 +5,76 @@ 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)); - } + public TomlXcoreConfig serverLocalConfig(@Named("pretty") Gson gson) { + var result = ConfigTomlLoader.loadXcoreConfig(dataDirectory, gson); + logSource("Config", result.file, result.source); + TomlXcoreConfig config = result.config; config.normalize(); return config; } @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"); + public Config config(TomlXcoreConfig serverLocalConfig) { + Config config = ConfigTomlMapper.toConfig(serverLocalConfig); + config.normalize(); + return config; + } - GlobalConfig globalConfig; - if (globalConfigFile.exists()) { - globalConfig = gson.fromJson(globalConfigFile.reader(), GlobalConfig.class); - } else { - globalConfig = new GlobalConfig(); - globalConfigFile.writeString(gson.toJson(globalConfig)); - } + @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 TomlSecretsConfig tomlSecretsConfig(Config config, @Named("pretty") Gson gson) { + var result = ConfigTomlLoader.loadTomlSecretsConfig(config.globalConfigDirectory, gson); + logSource("GlobalConfig", result.file, result.source); + + TomlSecretsConfig tomlSecretsConfig = result.config; + tomlSecretsConfig.normalize(); + return tomlSecretsConfig; + } + + @Bean + public GlobalConfig globalConfig(Config config, TomlSecretsConfig tomlSecretsConfig) { + GlobalConfig globalConfig = ConfigTomlMapper.toGlobalConfig(tomlSecretsConfig); globalConfig.normalize(); - globalConfig.postInit(globalConfigFile); + globalConfig.postInit(ConfigTomlLoader.resolveSecretsToml(config.globalConfigDirectory)); 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..3c793955 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ConfigTomlLoader.java @@ -0,0 +1,313 @@ +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.
  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, such as {@link TomlXcoreConfig}, {@link TomlSecretsConfig}, 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 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 TomlXcoreConfig} + */ + public static LoadResult loadXcoreConfig(Fi dataDirectory, Gson gson) { + Fi tomlFile = resolveXcoreToml(dataDirectory); + if (tomlFile.exists()) { + TomlXcoreConfig toml = readToml(tomlFile, TomlXcoreConfig.class); + toml.normalize(); + return new LoadResult<>(toml, Source.TOML, tomlFile); + } + + Fi jsonFile = resolveLegacyXcoreJson(dataDirectory); + if (jsonFile.exists()) { + return migrateLegacyXcoreConfig(jsonFile, tomlFile, gson); + } + + ConfigTomlTemplateWriter.writeDefaultXcoreToml(tomlFile); + TomlXcoreConfig toml = readToml(tomlFile, TomlXcoreConfig.class); + toml.normalize(); + return new LoadResult<>(toml, 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) { + LoadResult result = loadTomlSecretsConfig(globalConfigDirectory, gson); + GlobalConfig global = ConfigTomlMapper.toGlobalConfig(result.config); + return new LoadResult<>(global, result.source, result.file, result.backupFile); + } + + /** + * Loads the shared global/secrets configuration as the structured TOML DTO following the TOML-first resolution order. + * + *

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

+ * + * @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 TomlSecretsConfig} + */ + public static LoadResult loadTomlSecretsConfig(String globalConfigDirectory, Gson gson) { + Fi tomlFile = resolveSecretsToml(globalConfigDirectory); + if (tomlFile.exists()) { + TomlSecretsConfig toml = readToml(tomlFile, TomlSecretsConfig.class); + toml.normalize(); + return new LoadResult<>(toml, Source.TOML, tomlFile); + } + + Fi jsonFile = resolveLegacySecretsJson(globalConfigDirectory); + if (jsonFile.exists()) { + return migrateLegacySecretsConfig(jsonFile, tomlFile, gson); + } + + ConfigTomlTemplateWriter.writeDefaultSecretsToml(tomlFile); + TomlSecretsConfig toml = readToml(tomlFile, TomlSecretsConfig.class); + toml.normalize(); + return new LoadResult<>(toml, 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; + 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(); + } + legacyConfig.normalize(); + + TomlXcoreConfig migratedToml = ConfigTomlMapper.toTomlXcoreConfig(legacyConfig); + writeToml(tomlFile, migratedToml); + Fi backupFile = backupLegacyFile(jsonFile); + + 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(); + } + legacyGlobal.normalize(); + + TomlSecretsConfig migratedToml = ConfigTomlMapper.toTomlSecretsConfig(legacyGlobal); + writeToml(tomlFile, migratedToml); + Fi backupFile = backupLegacyFile(jsonFile); + + TomlSecretsConfig reloadedToml = readToml(tomlFile, TomlSecretsConfig.class); + reloadedToml.normalize(); + return new LoadResult<>(reloadedToml, 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 = HumanReadableTomlWriter.write(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..e746b40c --- /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 = blankToNull(src.baseUrl); + dst.model = blankToNull(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/HumanReadableTomlWriter.java b/src/main/java/org/xcore/plugin/config/HumanReadableTomlWriter.java new file mode 100644 index 00000000..05a31ea6 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/HumanReadableTomlWriter.java @@ -0,0 +1,127 @@ +package org.xcore.plugin.config; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Renders structured config DTOs as section-based, human-readable TOML. + * + *

Jackson's TOML writer currently emits dotted root keys like + * {@code server.name = "event"}. That is technically valid TOML but hard for + * operators to edit. This helper converts normalized config DTOs into a nested + * map view and writes standard table sections such as {@code [server]} and + * {@code [transport.redis]}.

+ */ +public final class HumanReadableTomlWriter { + private static final ObjectMapper MAP_MAPPER = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private HumanReadableTomlWriter() { + } + + public static String write(Object config) { + Objects.requireNonNull(config, "config must not be null"); + + LinkedHashMap map = MAP_MAPPER.convertValue(config, MAP_TYPE); + StringBuilder builder = new StringBuilder(); + appendSection(builder, null, map); + return builder.toString().trim(); + } + + @SuppressWarnings("unchecked") + private static void appendSection(StringBuilder builder, String sectionName, Map map) { + LinkedHashMap scalars = new LinkedHashMap<>(); + LinkedHashMap> tables = new LinkedHashMap<>(); + + for (Map.Entry entry : map.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + continue; + } + if (value instanceof Map nested) { + tables.put(entry.getKey(), (Map) nested); + } else { + scalars.put(entry.getKey(), value); + } + } + + if (sectionName != null && !scalars.isEmpty()) { + builder.append('[').append(sectionName).append(']').append("\n"); + } + + for (Map.Entry entry : scalars.entrySet()) { + builder.append(entry.getKey()) + .append(" = ") + .append(formatValue(entry.getValue())) + .append("\n"); + } + + if ((sectionName != null && !scalars.isEmpty() && !tables.isEmpty()) + || (sectionName == null && !scalars.isEmpty() && !tables.isEmpty())) { + builder.append("\n"); + } + + boolean firstNested = true; + for (Map.Entry> entry : tables.entrySet()) { + String nestedSection = sectionName == null ? entry.getKey() : sectionName + "." + entry.getKey(); + StringBuilder nestedBuilder = new StringBuilder(); + appendSection(nestedBuilder, nestedSection, entry.getValue()); + String renderedNested = nestedBuilder.toString().trim(); + if (renderedNested.isEmpty()) { + continue; + } + if (!firstNested) { + builder.append("\n"); + } + builder.append(renderedNested).append("\n"); + firstNested = false; + } + } + + @SuppressWarnings("unchecked") + private static String formatValue(Object value) { + return switch (value) { + case String string -> '"' + escapeString(string) + '"'; + case Number number -> number.toString(); + case Boolean bool -> bool.toString(); + case List list -> formatList(list); + case Iterable iterable -> formatList(iterableToList(iterable)); + default -> '"' + escapeString(String.valueOf(value)) + '"'; + }; + } + + private static String formatList(List list) { + StringBuilder builder = new StringBuilder("["); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(formatValue(list.get(i))); + } + return builder.append(']').toString(); + } + + private static List iterableToList(Iterable iterable) { + return java.util.stream.StreamSupport.stream(iterable.spliterator(), false) + .map(value -> (Object) value) + .toList(); + } + + private static String escapeString(String value) { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} 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..0dca5211 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigPathEditor.java @@ -0,0 +1,222 @@ +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. + */ +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 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"); + + PathBinding binding = PATH_BINDINGS.get(fieldPath.trim()); + if (binding == null) { + return null; + } + + 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; + } + + binding.valueType().apply(target.parent(), target.key(), value, prettyGson); + + TomlXcoreConfig updatedToml = prettyGson.fromJson(root, TomlXcoreConfig.class); + updatedToml.normalize(); + return 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..ecc0afe5 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlRenderer.java @@ -0,0 +1,16 @@ +package org.xcore.plugin.config; + +import java.util.Objects; + +/** + * 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(TomlXcoreConfig config) { + Objects.requireNonNull(config, "config must not be null"); + config.normalize(); + return HumanReadableTomlWriter.write(config); + } +} 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..a928e0d0 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/ServerLocalConfigTomlStore.java @@ -0,0 +1,64 @@ +package org.xcore.plugin.config; + +import arc.files.Fi; +import java.nio.file.Files; +import java.util.Objects; + +/** + * 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. {@link TomlXcoreConfig} is the direct helper + * boundary.

+ */ +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 TomlXcoreConfig} to the configured + * {@code xcore.toml} file. + * + *

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

+ * + * @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(TomlXcoreConfig config) { + Objects.requireNonNull(config, "config must not be null"); + config.normalize(); + writeToml(tomlFile, config); + } + + /** + * 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 = HumanReadableTomlWriter.write(config); + file.writeString(tomlString); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to write TOML to " + file.absolutePath() + ": " + e.getMessage(), e); + } + } +} 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..60420249 --- /dev/null +++ b/src/main/java/org/xcore/plugin/config/TomlSecretsConfig.java @@ -0,0 +1,247 @@ +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 = ""; + public String model = ""; + 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 ("openai".equalsIgnoreCase(type)) { + 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/main/java/org/xcore/plugin/database/MongoFactory.java b/src/main/java/org/xcore/plugin/database/MongoFactory.java index 8ac77d95..68913dba 100644 --- a/src/main/java/org/xcore/plugin/database/MongoFactory.java +++ b/src/main/java/org/xcore/plugin/database/MongoFactory.java @@ -11,7 +11,7 @@ import org.bson.codecs.configuration.CodecRegistry; import org.bson.codecs.pojo.PojoCodecProvider; import org.xcore.plugin.common.PLog; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import static org.bson.codecs.configuration.CodecRegistries.fromProviders; import static org.bson.codecs.configuration.CodecRegistries.fromRegistries; @@ -20,16 +20,16 @@ @Factory public class MongoFactory { - private final GlobalConfig globalConfig; + private final TomlSecretsConfig config; private MongoClient mongoClient; - public MongoFactory(GlobalConfig globalConfig) { - this.globalConfig = globalConfig; + public MongoFactory(TomlSecretsConfig config) { + this.config = config; } @Bean public MongoClient mongoClient() { - var connectionString = new ConnectionString(globalConfig.mongoConnectionString); + var connectionString = new ConnectionString(config.database.mongoConnectionString); var settings = MongoClientSettings.builder() .applyConnectionString(connectionString) .build(); @@ -45,8 +45,8 @@ public MongoDatabase mongoDatabase(MongoClient client) { fromProviders(PojoCodecProvider.builder().automatic(true).build()) ); - PLog.info("MongoDB: using database '@'", globalConfig.databaseName); - return client.getDatabase(globalConfig.databaseName) + PLog.info("MongoDB: using database '@'", config.database.name); + return client.getDatabase(config.database.name) .withCodecRegistry(pojoCodecRegistry); } 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..8ad55510 100644 --- a/src/main/java/org/xcore/plugin/database/migration/MigrationService.java +++ b/src/main/java/org/xcore/plugin/database/migration/MigrationService.java @@ -10,8 +10,8 @@ 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.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import java.util.Comparator; import java.util.List; @@ -19,13 +19,13 @@ @Singleton public class MigrationService { private final MongoDatabase database; - private final GlobalConfig globalConfig; - private final Config config; + private final TomlSecretsConfig secretsConfig; + private final TomlXcoreConfig config; private final List migrations; - public MigrationService(MongoDatabase database, GlobalConfig globalConfig, Config config, List migrations) { + public MigrationService(MongoDatabase database, TomlSecretsConfig secretsConfig, TomlXcoreConfig config, List migrations) { this.database = database; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.config = config; this.migrations = migrations.stream() .sorted(Comparator.comparingInt(Migration::getVersion)) @@ -33,7 +33,7 @@ public MigrationService(MongoDatabase database, GlobalConfig globalConfig, Confi } public boolean run() { - if (globalConfig.isDataBaseReadOnly) { + if (secretsConfig.database.readOnly) { Log.info("[Migrations] Database is in Read-Only mode. Skipping."); return true; } @@ -53,9 +53,9 @@ public boolean run() { return true; } - if (!globalConfig.isDataBaseMigration) { + if (!secretsConfig.database.migrationEnabled) { Log.warn("[Migrations] Update needed (v@ -> v@) but migrations are disabled.", dbVersion, targetVersion); - globalConfig.isDataBaseReadOnly = true; + secretsConfig.database.readOnly = true; return true; } @@ -68,8 +68,8 @@ 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); - globalConfig.isDataBaseReadOnly = true; + Log.warn("[Migrations] This server (@) will enter Read-Only mode until restart.", config.server.name); + secretsConfig.database.readOnly = 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/database/repository/AdminDataRepository.java b/src/main/java/org/xcore/plugin/database/repository/AdminDataRepository.java index cde41ea0..bf2b611c 100644 --- a/src/main/java/org/xcore/plugin/database/repository/AdminDataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/AdminDataRepository.java @@ -3,15 +3,15 @@ import com.mongodb.client.MongoDatabase; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.PlayerData; @Singleton public class AdminDataRepository extends PlayerDataRepository { @Inject - public AdminDataRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database, globalConfig); + public AdminDataRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database, secretsConfig); } public void delete(String uuid) { diff --git a/src/main/java/org/xcore/plugin/database/repository/AuditRecordRepository.java b/src/main/java/org/xcore/plugin/database/repository/AuditRecordRepository.java index 8d612c87..9bc862f6 100644 --- a/src/main/java/org/xcore/plugin/database/repository/AuditRecordRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/AuditRecordRepository.java @@ -7,7 +7,7 @@ import jakarta.inject.Singleton; import org.bson.Document; import org.bson.conversions.Bson; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.AuditAction; import org.xcore.plugin.model.AuditActorType; import org.xcore.plugin.model.AuditCursor; @@ -31,8 +31,8 @@ public class AuditRecordRepository extends DataRepository { private static final int DEFAULT_LIMIT = 20; @Inject - public AuditRecordRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database, "moderation_audit", AuditRecord.class, globalConfig); + public AuditRecordRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database, "moderation_audit", AuditRecord.class, secretsConfig); collection.createIndex(new Document("target.uuid", 1) .append("created_at_epoch_ms", -1) diff --git a/src/main/java/org/xcore/plugin/database/repository/BanDataRepository.java b/src/main/java/org/xcore/plugin/database/repository/BanDataRepository.java index 0653bc34..d2d98686 100644 --- a/src/main/java/org/xcore/plugin/database/repository/BanDataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/BanDataRepository.java @@ -5,7 +5,7 @@ import com.mongodb.client.model.ReplaceOptions; import jakarta.inject.Singleton; import jakarta.inject.Inject; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.MongoUtils; import org.xcore.plugin.database.PagedDataResult; import org.xcore.plugin.model.BanData; @@ -18,8 +18,8 @@ public class BanDataRepository extends DataRepository { @Inject - public BanDataRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database, "bans", BanData.class, globalConfig); + public BanDataRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database, "bans", BanData.class, secretsConfig); } public BanData find(String uuid, String ip) { diff --git a/src/main/java/org/xcore/plugin/database/repository/DataRepository.java b/src/main/java/org/xcore/plugin/database/repository/DataRepository.java index 7895a396..bd15be88 100644 --- a/src/main/java/org/xcore/plugin/database/repository/DataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/DataRepository.java @@ -8,7 +8,7 @@ import org.bson.Document; import org.bson.conversions.Bson; import org.bson.types.ObjectId; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.ModelData; import static com.mongodb.client.model.Filters.eq; @@ -17,12 +17,12 @@ public abstract class DataRepository { protected final MongoDatabase database; protected final MongoCollection collection; - protected final GlobalConfig globalConfig; + protected final TomlSecretsConfig secretsConfig; - protected DataRepository(MongoDatabase database, String collectionName, Class clazz, GlobalConfig globalConfig) { + protected DataRepository(MongoDatabase database, String collectionName, Class clazz, TomlSecretsConfig secretsConfig) { this.database = database; this.collection = database.getCollection(collectionName, clazz); - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; collection.createIndex(new Document("version", -1)); collection.createIndex(new Document("is_visible", -1)); @@ -55,7 +55,7 @@ public T findById(ObjectId id) { } public boolean isReadOnly() { - return globalConfig.isDataBaseReadOnly; + return secretsConfig.database.readOnly; } public long count() { diff --git a/src/main/java/org/xcore/plugin/database/repository/EventDataRepository.java b/src/main/java/org/xcore/plugin/database/repository/EventDataRepository.java index 900e0644..7684f880 100644 --- a/src/main/java/org/xcore/plugin/database/repository/EventDataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/EventDataRepository.java @@ -8,7 +8,7 @@ import org.bson.conversions.Bson; import org.bson.types.ObjectId; import org.xcore.plugin.common.StatusEnum; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.EventData; import java.util.ArrayList; @@ -22,8 +22,8 @@ public class EventDataRepository extends DataRepository { @Inject - public EventDataRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database, "events", EventData.class, globalConfig); + public EventDataRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database, "events", EventData.class, secretsConfig); collection.createIndex(new Document("name", 1).append("map", 1).append("author", 1)); collection.createIndex(new Document("is_active", -1)); diff --git a/src/main/java/org/xcore/plugin/database/repository/GameDataRepository.java b/src/main/java/org/xcore/plugin/database/repository/GameDataRepository.java index 7951930c..8f69e1e0 100644 --- a/src/main/java/org/xcore/plugin/database/repository/GameDataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/GameDataRepository.java @@ -9,7 +9,7 @@ import jakarta.inject.Singleton; import org.bson.Document; import org.xcore.plugin.model.AggregatedPlayerStats; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.GameData; import org.xcore.plugin.model.ModeStatsSummary; import org.xcore.plugin.model.PlayerStatsOverview; @@ -26,8 +26,8 @@ public class GameDataRepository extends DataRepository { private static final String COLLECTION_NAME = "games_v2"; @Inject - public GameDataRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database, COLLECTION_NAME, GameData.class, globalConfig); + public GameDataRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database, COLLECTION_NAME, GameData.class, secretsConfig); collection.createIndex(new Document("map", 1)); collection.createIndex(new Document("event", 1)); diff --git a/src/main/java/org/xcore/plugin/database/repository/MapDataRepository.java b/src/main/java/org/xcore/plugin/database/repository/MapDataRepository.java index dbafb47e..2b2dfc2c 100644 --- a/src/main/java/org/xcore/plugin/database/repository/MapDataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/MapDataRepository.java @@ -12,7 +12,7 @@ import org.bson.Document; import org.bson.types.ObjectId; import org.xcore.plugin.common.PLog; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.MapData; import java.util.Optional; @@ -25,8 +25,8 @@ public class MapDataRepository extends DataRepository { @Inject - public MapDataRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database, "maps", MapData.class, globalConfig); + public MapDataRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database, "maps", MapData.class, secretsConfig); collection.createIndex(new Document("name", -1)); collection.createIndex(new Document("author", -1)); diff --git a/src/main/java/org/xcore/plugin/database/repository/MuteDataRepository.java b/src/main/java/org/xcore/plugin/database/repository/MuteDataRepository.java index 920efcc1..dc789a92 100644 --- a/src/main/java/org/xcore/plugin/database/repository/MuteDataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/MuteDataRepository.java @@ -5,7 +5,7 @@ import com.mongodb.client.model.ReplaceOptions; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.MuteData; import static com.mongodb.client.model.Filters.eq; @@ -14,8 +14,8 @@ public class MuteDataRepository extends DataRepository { @Inject - public MuteDataRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database,"mutes", MuteData.class, globalConfig); + public MuteDataRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database,"mutes", MuteData.class, secretsConfig); } public MuteData findByUuid(String uuid) { diff --git a/src/main/java/org/xcore/plugin/database/repository/PlayerDataRepository.java b/src/main/java/org/xcore/plugin/database/repository/PlayerDataRepository.java index 1a6e2818..8417442c 100644 --- a/src/main/java/org/xcore/plugin/database/repository/PlayerDataRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/PlayerDataRepository.java @@ -14,7 +14,7 @@ import org.bson.Document; import org.bson.conversions.Bson; import org.xcore.plugin.common.StatusEnum; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.MongoUtils; import org.xcore.plugin.database.PagedDataResult; import org.xcore.plugin.model.LeaderboardCursor; @@ -40,9 +40,9 @@ public class PlayerDataRepository extends DataRepository { private final MongoCollection counters; @Inject - public PlayerDataRepository(MongoDatabase database, GlobalConfig globalConfig) { + public PlayerDataRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { - super(database, "players", PlayerData.class, globalConfig); + super(database, "players", PlayerData.class, secretsConfig); this.counters = database.getCollection("counters"); collection.createIndex(new Document("uuid", 1), new IndexOptions().unique(true)); diff --git a/src/main/java/org/xcore/plugin/database/repository/PrivateMessageRepository.java b/src/main/java/org/xcore/plugin/database/repository/PrivateMessageRepository.java index 2a772468..b6fe87b4 100644 --- a/src/main/java/org/xcore/plugin/database/repository/PrivateMessageRepository.java +++ b/src/main/java/org/xcore/plugin/database/repository/PrivateMessageRepository.java @@ -8,7 +8,7 @@ import jakarta.inject.Singleton; import org.bson.Document; import org.bson.types.ObjectId; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.PrivateMessage; import java.util.ArrayList; @@ -20,8 +20,8 @@ public class PrivateMessageRepository extends DataRepository { @Inject - public PrivateMessageRepository(MongoDatabase database, GlobalConfig globalConfig) { - super(database, "private_messages", PrivateMessage.class, globalConfig); + public PrivateMessageRepository(MongoDatabase database, TomlSecretsConfig secretsConfig) { + super(database, "private_messages", PrivateMessage.class, secretsConfig); collection.createIndex( new Document("to_uuid", 1) 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..e6d1947e 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.TomlSecretsConfig; +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,8 +35,8 @@ public class ConnectionHandler { private final SessionService sessionService; private final AdminDataRepository adminDataRepository; private final NetworkService network; - private final Config config; - private final GlobalConfig globalConfig; + private final TomlXcoreConfig config; + private final TomlSecretsConfig secretsConfig; private final VoteService voteService; private final PrivateMessageService privateMessageService; private final PlayerDisplayService playerDisplayService; @@ -47,8 +47,8 @@ public class ConnectionHandler { public ConnectionHandler(SessionService sessionService, AdminDataRepository adminDataRepository, NetworkService network, - Config config, - GlobalConfig globalConfig, + TomlXcoreConfig config, + TomlSecretsConfig secretsConfig, VoteService voteService, PrivateMessageService privateMessageService, PlayerDisplayService playerDisplayService, @@ -58,7 +58,7 @@ public ConnectionHandler(SessionService sessionService, this.adminDataRepository = adminDataRepository; this.network = network; this.config = config; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.voteService = voteService; this.privateMessageService = privateMessageService; this.playerDisplayService = playerDisplayService; @@ -115,7 +115,7 @@ public void onPlayerJoin(PlayerJoin event) { playerDisplayService.refresh(session); if (player.getInfo().timesJoined < 5) { - Call.openURI(player.con, globalConfig.discordUrl); + Call.openURI(player.con, secretsConfig.externalLinks.discordUrl); } long unreadMessages = privateMessageService.countUnread(data.uuid); @@ -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/OpenAIRequestFactory.java b/src/main/java/org/xcore/plugin/localization/OpenAIRequestFactory.java index c4293f58..71491bc0 100644 --- a/src/main/java/org/xcore/plugin/localization/OpenAIRequestFactory.java +++ b/src/main/java/org/xcore/plugin/localization/OpenAIRequestFactory.java @@ -2,7 +2,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.service.TranslationSafetyService; import java.net.URI; @@ -15,10 +15,10 @@ final class OpenAIRequestFactory { static final String API_MODE_RESPONSES = "responses"; static final String API_MODE_CHAT_COMPLETIONS = "chat_completions"; - private final GlobalConfig.TranslationProviderConfig providerConfig; + private final TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig; private final TranslationSafetyService translationSafetyService; - OpenAIRequestFactory(GlobalConfig.TranslationProviderConfig providerConfig, + OpenAIRequestFactory(TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig, TranslationSafetyService translationSafetyService) { this.providerConfig = providerConfig; this.translationSafetyService = translationSafetyService; diff --git a/src/main/java/org/xcore/plugin/localization/OpenAITranslationProvider.java b/src/main/java/org/xcore/plugin/localization/OpenAITranslationProvider.java index 0ce80e7f..f0901660 100644 --- a/src/main/java/org/xcore/plugin/localization/OpenAITranslationProvider.java +++ b/src/main/java/org/xcore/plugin/localization/OpenAITranslationProvider.java @@ -3,7 +3,7 @@ import arc.func.Cons; import com.google.gson.Gson; import com.google.gson.JsonObject; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.service.TranslationSafetyService; import java.io.IOException; @@ -22,7 +22,7 @@ public final class OpenAITranslationProvider implements TranslationProvider { private static final Gson GSON = new Gson(); private final String providerId; - private final GlobalConfig.TranslationProviderConfig providerConfig; + private final TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig; private final TranslationSafetyService translationSafetyService; private final TranslationExecutor translationExecutor; private final HttpClient client; @@ -32,7 +32,7 @@ public final class OpenAITranslationProvider implements TranslationProvider { private final OpenAIRetryPolicy retryPolicy; public OpenAITranslationProvider(String providerId, - GlobalConfig.TranslationProviderConfig providerConfig, + TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig, TranslationSafetyService translationSafetyService, TranslationExecutor translationExecutor) { this.providerId = providerId; diff --git a/src/main/java/org/xcore/plugin/localization/TranslationProviderFactory.java b/src/main/java/org/xcore/plugin/localization/TranslationProviderFactory.java index 05495847..feee3963 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.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationSafetyService; import java.util.ArrayList; @@ -14,13 +14,13 @@ @Factory public class TranslationProviderFactory { - private final Config config; - private final GlobalConfig globalConfig; + private final TomlXcoreConfig config; + private final TomlSecretsConfig secretsConfig; @Inject - public TranslationProviderFactory(Config config, GlobalConfig globalConfig) { + public TranslationProviderFactory(TomlXcoreConfig config, TomlSecretsConfig secretsConfig) { this.config = config; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; } @Bean @@ -38,7 +38,7 @@ public TranslationProviderPipeline translationProviderPipeline(GoogleTranslation continue; } - GlobalConfig.TranslationProviderConfig providerConfig = globalConfig.translationProviders.get(providerId); + TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig = secretsConfig.translation.providers.get(providerId); if (providerConfig == null) { PLog.err("Translation provider '@' is referenced in pipeline but missing in global config", providerId); continue; @@ -71,7 +71,7 @@ public TranslationProviderPipeline translationProviderPipeline(GoogleTranslation } private TranslationProvider createProvider(String providerId, - GlobalConfig.TranslationProviderConfig providerConfig, + TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig, GoogleTranslationProvider googleTranslationProvider, TranslationSafetyService translationSafetyService, TranslationExecutor translationExecutor) { 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/BanCheck.java b/src/main/java/org/xcore/plugin/security/ingress/checks/BanCheck.java index c5ebc9f1..6349c527 100644 --- a/src/main/java/org/xcore/plugin/security/ingress/checks/BanCheck.java +++ b/src/main/java/org/xcore/plugin/security/ingress/checks/BanCheck.java @@ -4,7 +4,7 @@ import jakarta.inject.Singleton; import mindustry.net.NetConnection; import mindustry.net.Packets.ConnectPacket; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.repository.BanDataRepository; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.BanData; @@ -27,12 +27,12 @@ public class BanCheck implements IngressCheck { private final BanDataRepository banDataRepository; private final Bundle bundle; - private final GlobalConfig globalConfig; + private final TomlSecretsConfig secretsConfig; - public BanCheck(BanDataRepository banDataRepository, Bundle bundle, GlobalConfig globalConfig) { + public BanCheck(BanDataRepository banDataRepository, Bundle bundle, TomlSecretsConfig secretsConfig) { this.banDataRepository = banDataRepository; this.bundle = bundle; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; } @Override @@ -61,7 +61,7 @@ public AccessResult check(NetConnection con, ConnectPacket packet) { "days", duration.toDays(), "hours", duration.toHoursPart(), "minutes", duration.toMinutesPart(), - "discordUrl", globalConfig.discordUrl + "discordUrl", secretsConfig.externalLinks.discordUrl )); return new AccessResult.Denied(reason, false, 0); @@ -73,7 +73,7 @@ public AccessResult check(NetConnection con, ConnectPacket packet) { String reason = local.format("ban-content", args( "nickname", stripColors(packet.name), - "discordUrl", globalConfig.discordUrl + "discordUrl", secretsConfig.externalLinks.discordUrl )); return new AccessResult.Denied(reason, false, 0); diff --git a/src/main/java/org/xcore/plugin/security/ingress/checks/IpReputationCheck.java b/src/main/java/org/xcore/plugin/security/ingress/checks/IpReputationCheck.java index b766f720..823df90e 100644 --- a/src/main/java/org/xcore/plugin/security/ingress/checks/IpReputationCheck.java +++ b/src/main/java/org/xcore/plugin/security/ingress/checks/IpReputationCheck.java @@ -4,7 +4,7 @@ import jakarta.inject.Singleton; import mindustry.net.NetConnection; import mindustry.net.Packets; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.common.PLog; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.security.ingress.AccessResult; @@ -27,19 +27,19 @@ public class IpReputationCheck implements IngressCheck { private final IpReputationService ipReputationService; private final Bundle bundle; - private final GlobalConfig globalConfig; + private final TomlSecretsConfig secretsConfig; /** * Constructs an IpReputationCheck that evaluates connection IPs against an IP reputation service. * * @param ipReputationService service used to determine whether an IP is blocked (e.g., proxy/VPN/TOR) * @param bundle localization bundle used to format denial messages - * @param globalConfig configuration providing values included in denial messages (notably `discordUrl`) + * @param secretsConfig configuration providing values included in denial messages (notably `discordUrl`) */ - public IpReputationCheck(IpReputationService ipReputationService, Bundle bundle, GlobalConfig globalConfig) { + public IpReputationCheck(IpReputationService ipReputationService, Bundle bundle, TomlSecretsConfig secretsConfig) { this.ipReputationService = ipReputationService; this.bundle = bundle; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; } /** @@ -67,7 +67,7 @@ public AccessResult check(NetConnection con, Packets.ConnectPacket packet) { if (ipReputationService.isBlocked(normalized)) { Localization local = new Localization(bundle, bundle.resolveLocale(packet.locale)); String reason = local.format("ip-reputation-denied", args( - "discordUrl", globalConfig.discordUrl + "discordUrl", secretsConfig.externalLinks.discordUrl )); return new AccessResult.Denied(reason, false, 0); } 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/IpApiProvider.java b/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpApiProvider.java index d255ab71..74edb9ff 100644 --- a/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpApiProvider.java +++ b/src/main/java/org/xcore/plugin/security/ingress/ipreputation/IpApiProvider.java @@ -5,7 +5,7 @@ import com.google.gson.JsonParseException; import jakarta.inject.Singleton; import org.xcore.plugin.common.PLog; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -35,7 +35,7 @@ public class IpApiProvider implements IpReputationProvider { private static final Gson GSON = new Gson(); private static final long RATE_LIMIT_WINDOW_MILLIS = 60_000L; - private final GlobalConfig.IpReputationProviderConfig providerConfig; + private final TomlSecretsConfig.IpReputationSection.ProviderConfig providerConfig; private final HttpClient client; private final Deque requestTimestamps = new ArrayDeque<>(); @@ -43,24 +43,24 @@ public class IpApiProvider implements IpReputationProvider { * Creates a new IpApiProvider using values from the provided global configuration. * * The provider will use an HttpClient configured with a connect timeout derived from - * globalConfig.ipReputationProvider.timeoutSeconds. + * secretsConfig.ipReputation.provider.timeoutSeconds. * - * @param globalConfig global configuration containing ip reputation provider settings + * @param secretsConfig structured secrets configuration containing ip reputation provider settings */ - public IpApiProvider(GlobalConfig globalConfig) { - this(globalConfig, HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(globalConfig.ipReputationProvider.timeoutSeconds)) + public IpApiProvider(TomlSecretsConfig secretsConfig) { + this(secretsConfig, HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(secretsConfig.ipReputation.provider.timeoutSeconds)) .build()); } /** * Package-private constructor used for testing; initializes the provider with the given configuration and HTTP client. * - * @param globalConfig supplies the {@code ipReputationProvider} settings (base URL, timeouts, retries, rate limits) + * @param secretsConfig supplies the structured ip reputation provider settings (base URL, timeouts, retries, rate limits) * @param client the {@link HttpClient} to use for HTTP requests (injected, typically a test client) */ - IpApiProvider(GlobalConfig globalConfig, HttpClient client) { - this.providerConfig = globalConfig.ipReputationProvider; + IpApiProvider(TomlSecretsConfig secretsConfig, HttpClient client) { + this.providerConfig = secretsConfig.ipReputation.provider; this.client = client; } 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..2e000b54 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.TomlSecretsConfig; +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,8 +40,8 @@ public class MapService { private final EventDataRepository eventDataRepository; private final MapDataRepository mapDataRepository; private final SessionService sessionService; - private final Config config; - private final GlobalConfig globalConfig; + private final TomlXcoreConfig config; + private final TomlSecretsConfig secretsConfig; private final VoteService voteService; private final VoteNewWaveFactory voteNewWaveFactory; private final VoteRtvFactory voteRtvFactory; @@ -51,8 +51,8 @@ public class MapService { public MapService(EventDataRepository eventDataRepository, MapDataRepository mapDataRepository, SessionService sessionService, - Config config, - GlobalConfig globalConfig, + TomlXcoreConfig config, + TomlSecretsConfig secretsConfig, VoteService voteService, VoteNewWaveFactory voteNewWaveFactory, VoteRtvFactory voteRtvFactory, @@ -61,7 +61,7 @@ public MapService(EventDataRepository eventDataRepository, this.mapDataRepository = mapDataRepository; this.sessionService = sessionService; this.config = config; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.voteService = voteService; this.voteNewWaveFactory = voteNewWaveFactory; this.voteRtvFactory = voteRtvFactory; @@ -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; } @@ -250,7 +250,7 @@ private void switchMapImmediately(Map target) { Gamemode mode = Gamemode.valueOf(arc.Core.settings.getString("lastServerMode", "survival")); Vars.world.loadMap(target, target.applyRules(mode)); }), - globalConfig.mapSwitchDelaySeconds + secretsConfig.maps.voting.switchDelaySeconds ); } @@ -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/PlayerActivityService.java b/src/main/java/org/xcore/plugin/service/PlayerActivityService.java index aad52029..c443b0db 100644 --- a/src/main/java/org/xcore/plugin/service/PlayerActivityService.java +++ b/src/main/java/org/xcore/plugin/service/PlayerActivityService.java @@ -4,7 +4,7 @@ import io.avaje.inject.PostConstruct; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.Session; @@ -17,17 +17,17 @@ public class PlayerActivityService { private final SessionService sessionService; private final FindService findService; - private final GlobalConfig globalConfig; + private final TomlSecretsConfig secretsConfig; private final ServerDiscoveryService discoveryService; @Inject public PlayerActivityService(SessionService sessionService, FindService findService, - GlobalConfig globalConfig, + TomlSecretsConfig secretsConfig, ServerDiscoveryService discoveryService) { this.sessionService = sessionService; this.findService = findService; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.discoveryService = discoveryService; } @@ -46,13 +46,13 @@ public void start() { sessionService.incrementPlayTime(session, 1); - if (data.totalPlayTime == globalConfig.minPlayTimeForVotekick) { + if (data.totalPlayTime == secretsConfig.moderation.votekick.minPlayTimeMinutes) { local.send("notification-votekick-playtime", - args("votekickPlayTime", globalConfig.minPlayTimeForVotekick)); + args("votekickPlayTime", secretsConfig.moderation.votekick.minPlayTimeMinutes)); } - if (data.totalPlayTime == globalConfig.minPlayTimeForGlobalChat) { + if (data.totalPlayTime == secretsConfig.chat.global.minPlayTimeMinutes) { local.send("notification-global-chat-playtime", - args("globalChatPlayTime", globalConfig.minPlayTimeForGlobalChat)); + args("globalChatPlayTime", secretsConfig.chat.global.minPlayTimeMinutes)); } } 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..d42e2449 100644 --- a/src/main/java/org/xcore/plugin/service/PrivateMessageService.java +++ b/src/main/java/org/xcore/plugin/service/PrivateMessageService.java @@ -5,8 +5,8 @@ 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.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.database.repository.PrivateMessageRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.model.PrivateMessage; @@ -27,22 +27,22 @@ public class PrivateMessageService { private final SessionService sessionService; private final SecurityService securityService; private final NetworkService networkService; - private final Config config; - private final GlobalConfig globalConfig; + private final TomlXcoreConfig config; + private final TomlSecretsConfig secretsConfig; @Inject public PrivateMessageService(PrivateMessageRepository privateMessageRepository, SessionService sessionService, SecurityService securityService, NetworkService networkService, - Config config, - GlobalConfig globalConfig) { + TomlXcoreConfig config, + TomlSecretsConfig secretsConfig) { this.privateMessageRepository = privateMessageRepository; this.sessionService = sessionService; this.securityService = securityService; this.networkService = networkService; this.config = config; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; } public boolean send(Session senderSession, int targetPid, String rawMessage) { @@ -71,8 +71,8 @@ public boolean send(Session senderSession, int targetPid, String rawMessage) { return false; } - if (message.length() > globalConfig.privateMessageMaxLength) { - senderSession.locale().send("error-private-message-too-long", args("max", globalConfig.privateMessageMaxLength)); + if (message.length() > secretsConfig.messages.privateMessages.maxLength) { + senderSession.locale().send("error-private-message-too-long", args("max", secretsConfig.messages.privateMessages.maxLength)); return false; } @@ -87,7 +87,7 @@ public boolean send(Session senderSession, int targetPid, String rawMessage) { return false; } - if (privateMessageRepository.countUnread(targetData.uuid) >= globalConfig.privateMessageUnreadLimit) { + if (privateMessageRepository.countUnread(targetData.uuid) >= secretsConfig.messages.privateMessages.unreadLimit) { senderSession.locale().send("error-private-message-target-unavailable", args()); return false; } @@ -137,7 +137,7 @@ public long countInbox(String uuid) { public List inbox(String uuid, int page) { int safePage = Math.max(1, page); - int limit = Math.max(1, globalConfig.privateMessagesPerPage); + int limit = Math.max(1, secretsConfig.pagination.privateMessagesPerPage); int skip = (safePage - 1) * limit; return privateMessageRepository.findInbox(uuid, skip, limit); } @@ -197,8 +197,8 @@ public boolean block(Session session, int targetPid) { return false; } - if (session.data.blockedPrivateUuids.size() >= globalConfig.privateMessageBlockedLimit) { - session.locale().send("error-private-message-block-limit", args("limit", globalConfig.privateMessageBlockedLimit)); + if (session.data.blockedPrivateUuids.size() >= secretsConfig.messages.privateMessages.blockedLimit) { + session.locale().send("error-private-message-block-limit", args("limit", secretsConfig.messages.privateMessages.blockedLimit)); return false; } @@ -298,11 +298,11 @@ public Integer parseMenuPid(String input) { private boolean isRateLimited(Session session) { long diff = System.currentTimeMillis() - session.lastPrivateMessageAt; - return diff < globalConfig.privateMessageCooldownSeconds * 1000L; + return diff < secretsConfig.messages.privateMessages.cooldownSeconds * 1000L; } private long cooldownRemainingSeconds(Session session) { - long remainingMillis = globalConfig.privateMessageCooldownSeconds * 1000L - (System.currentTimeMillis() - session.lastPrivateMessageAt); + long remainingMillis = secretsConfig.messages.privateMessages.cooldownSeconds * 1000L - (System.currentTimeMillis() - session.lastPrivateMessageAt); return Math.max(1L, (long) Math.ceil(remainingMillis / 1000.0)); } @@ -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/session/Session.java b/src/main/java/org/xcore/plugin/session/Session.java index 50fae111..fdbbbaa1 100644 --- a/src/main/java/org/xcore/plugin/session/Session.java +++ b/src/main/java/org/xcore/plugin/session/Session.java @@ -8,7 +8,7 @@ import com.ospx.flubundle.Bundle; import org.xcore.plugin.cloud.XCoreSender; import org.xcore.plugin.common.StatusEnum; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; @@ -25,7 +25,7 @@ @AssistFactory(SessionFactory.class) @Data public class Session { - public final GlobalConfig globalConfig; + public final TomlSecretsConfig secretsConfig; public final Bundle bundle; public final MenuService menuService; public final PlayerDataRepository playerDataRepository; @@ -50,13 +50,13 @@ public class Session { private ActiveMenuScreen activeScreen; private ActiveMenuPrompt activePrompt; - public Session(GlobalConfig globalConfig, + public Session(TomlSecretsConfig secretsConfig, Bundle bundle, MenuService menuService, PlayerDataRepository playerDataRepository, @Assisted Player player, @Assisted PlayerData playerData) { - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.bundle = bundle; this.menuService = menuService; this.playerDataRepository = playerDataRepository; @@ -195,7 +195,7 @@ public void clearAllDrafts() { } public void pushHistory(Runnable menuLoader) { - if (history.size() >= globalConfig.maxHistory) { + if (history.size() >= secretsConfig.messages.history.maxHistory) { history.removeFirst(); } history.addLast(menuLoader); @@ -213,7 +213,7 @@ public void pushRouteHistory(MenuRoute route) { if (route == null) { return; } - if (routeHistory.size() >= globalConfig.maxHistory) { + if (routeHistory.size() >= secretsConfig.messages.history.maxHistory) { routeHistory.removeFirst(); } routeHistory.addLast(route); diff --git a/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryFlows.java b/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryFlows.java index 1aaabe17..0afd4ae0 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryFlows.java @@ -83,12 +83,12 @@ public MenuScreen render(MenuRenderContext c } var slice = switch (state.mode) { - case TARGET -> menu.auditService.findSummaryByTargetUuid(state.targetUuid, state.currentCursor, menu.globalConfig.eventsPerPage); + case TARGET -> menu.auditService.findSummaryByTargetUuid(state.targetUuid, state.currentCursor, menu.secretsConfig.pagination.eventsPerPage); case ACTOR -> menu.findSummaryByActor( AuditActorType.PLAYER_ADMIN, menu.actorLookupIds(state.targetDiscordId, state.targetNickname), state.currentCursor, - menu.globalConfig.eventsPerPage + menu.secretsConfig.pagination.eventsPerPage ); }; state.nextCursor = slice.nextCursor(); 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..cc7747b0 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/AuditHistoryMenu.java @@ -3,8 +3,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.AuditActorType; import org.xcore.plugin.model.AuditCursor; @@ -34,12 +33,11 @@ public class AuditHistoryMenu extends Menu { private final MenuService menuService; @Inject - public AuditHistoryMenu(Config config, - GlobalConfig globalConfig, + public AuditHistoryMenu(TomlSecretsConfig secretsConfig, SessionService sessionService, AuditService auditService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(secretsConfig, 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..38372884 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/BanMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/BanMenu.java @@ -4,8 +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.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.BanData; import org.xcore.plugin.service.TimeService; import org.xcore.plugin.service.moderation.ModerationService; @@ -38,13 +37,12 @@ public class BanMenu extends Menu { private final MenuService menuService; @Inject - public BanMenu(Config config, - GlobalConfig globalConfig, + public BanMenu(TomlSecretsConfig secretsConfig, SessionService sessionService, ModerationService moderationService, TimeService timeService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(secretsConfig, sessionService); this.moderationService = moderationService; this.timeService = timeService; this.menuService = menuService; diff --git a/src/main/java/org/xcore/plugin/ui/menu/DiscordFlows.java b/src/main/java/org/xcore/plugin/ui/menu/DiscordFlows.java index b6de4ead..060ae758 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/DiscordFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/DiscordFlows.java @@ -29,7 +29,7 @@ static final class MainFlow extends BaseMenuFlow { this.menu = menu; this.discordLinkService = discordLinkService; - action("open", ctx -> ctx.session().menuService.openUri(ctx.session(), menu.globalConfig.discordUrl)); + action("open", ctx -> ctx.session().menuService.openUri(ctx.session(), menu.secretsConfig.externalLinks.discordUrl)); action("status", ctx -> ctx.render()); action("link", ctx -> { ctx.session().clearDraft(LinkingState.class); @@ -74,7 +74,7 @@ public MenuScreen render(MenuRenderContext context) { local.t("discord-menu-title"), local.t("discord-menu-content", args( "status", statusText, - "discordUrl", menu.globalConfig.discordUrl + "discordUrl", menu.secretsConfig.externalLinks.discordUrl )), grid.build() ); @@ -90,7 +90,7 @@ static final class LinkingFlow extends BaseMenuFlow { this.menu = menu; this.discordLinkService = discordLinkService; - action("open", ctx -> ctx.session().menuService.openUri(ctx.session(), menu.globalConfig.discordUrl)); + action("open", ctx -> ctx.session().menuService.openUri(ctx.session(), menu.secretsConfig.externalLinks.discordUrl)); action("copy", ctx -> { var result = ctx.state().result; if (result != null && result.success()) { @@ -138,7 +138,7 @@ public MenuScreen render(MenuRenderContext context) { local.t("discord-link-menu-content", args( "code", "----", "expireMinutes", 0, - "discordUrl", menu.globalConfig.discordUrl + "discordUrl", menu.secretsConfig.externalLinks.discordUrl )), grid.build() ); @@ -161,7 +161,7 @@ public MenuScreen render(MenuRenderContext context) { local.t("discord-link-menu-content", args( "code", result.code(), "expireMinutes", result.remainingMinutes(System.currentTimeMillis()), - "discordUrl", menu.globalConfig.discordUrl + "discordUrl", menu.secretsConfig.externalLinks.discordUrl )), grid.build() ); 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..649e219d 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/DiscordMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/DiscordMenu.java @@ -3,8 +3,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.service.DiscordLinkService; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; @@ -20,12 +19,11 @@ public class DiscordMenu extends Menu { private final MenuService menuService; @Inject - public DiscordMenu(Config config, - GlobalConfig globalConfig, + public DiscordMenu(TomlSecretsConfig secretsConfig, SessionService sessionService, DiscordLinkService discordLinkService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(secretsConfig, sessionService); this.discordLinkService = discordLinkService; this.menuService = menuService; } diff --git a/src/main/java/org/xcore/plugin/ui/menu/EventDraftFlows.java b/src/main/java/org/xcore/plugin/ui/menu/EventDraftFlows.java index 92524263..d3ecc5bb 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/EventDraftFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/EventDraftFlows.java @@ -260,7 +260,7 @@ static final class MapSelectionFlow extends BaseMenuFlow { Session session = ctx.session(); int currentPage = Math.max(1, ctx.state().page); List pagedMaps = SeqStream.of(mapService.getAvailableMaps()) - .gather(CustomGatherers.page(menu.globalConfig.mapsPerPage, currentPage)) + .gather(CustomGatherers.page(menu.secretsConfig.pagination.mapsPerPage, currentPage)) .flatMap(List::stream) .toList(); if (index < 0 || index >= pagedMaps.size()) { @@ -283,11 +283,11 @@ public MapSelectionState createState(Session session, MenuRoute route, MapSelect public MenuScreen render(MenuRenderContext context) { Session session = context.session(); Seq maps = mapService.getAvailableMaps(); - var pagination = CustomGatherers.calculatePagination(maps.size, menu.globalConfig.mapsPerPage); + var pagination = CustomGatherers.calculatePagination(maps.size, menu.secretsConfig.pagination.mapsPerPage); int validPage = pagination.clampPage(Math.max(1, context.state().page)); List pagedMaps = SeqStream.of(maps) - .gather(CustomGatherers.page(menu.globalConfig.mapsPerPage, validPage)) + .gather(CustomGatherers.page(menu.secretsConfig.pagination.mapsPerPage, validPage)) .flatMap(List::stream) .toList(); diff --git a/src/main/java/org/xcore/plugin/ui/menu/EventFlows.java b/src/main/java/org/xcore/plugin/ui/menu/EventFlows.java index 155951c1..906d0ada 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/EventFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/EventFlows.java @@ -149,7 +149,7 @@ public EventsState createState(Session session, MenuRoute route, EventsState cur public MenuScreen render(MenuRenderContext context) { Session session = context.session(); int requestedPage = Math.max(1, context.state().page); - int perPage = menu.globalConfig.eventsPerPage; + int perPage = menu.secretsConfig.pagination.eventsPerPage; EventViewService.EventPage eventPage = eventViewService.page(requestedPage, perPage, session.sortStatus); String finishedFilter = session.locale().t("finished-" + session.sortStatus.getOrDefault("finished", StatusEnum.Neutral).name().toLowerCase()); String majorFilter = session.locale().t("major-" + session.sortStatus.getOrDefault("major", StatusEnum.Neutral).name().toLowerCase()); 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..bc6a56cf 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/EventMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/EventMenu.java @@ -4,8 +4,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.model.EventData; import org.xcore.plugin.model.MapData; import org.xcore.plugin.service.EventEditorService; @@ -30,11 +29,11 @@ public class EventMenu extends Menu { private final MenuService menuService; @Inject - public EventMenu(Config config, GlobalConfig globalConfig, SessionService sessionService, + public EventMenu(TomlSecretsConfig secretsConfig, SessionService sessionService, MapService mapService, EventService eventService, EventEditorService eventEditorService, EventViewService eventViewService, VoteService voteService, Provider mapMenu, MenuService menuService) { - super(config, globalConfig, sessionService); + super(secretsConfig, sessionService); this.mapService = mapService; this.eventService = eventService; this.eventEditorService = eventEditorService; diff --git a/src/main/java/org/xcore/plugin/ui/menu/HelpFlows.java b/src/main/java/org/xcore/plugin/ui/menu/HelpFlows.java index a30fd760..18a34131 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/HelpFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/HelpFlows.java @@ -68,10 +68,10 @@ public MenuScreen render(MenuRenderContext context) { return errorScreen(session); } - var pagination = CustomGatherers.calculatePagination(allCommands.size(), menu.globalConfig.commandsPerPage); + var pagination = CustomGatherers.calculatePagination(allCommands.size(), menu.secretsConfig.pagination.commandsPerPage); int currentPage = pagination.clampPage(page); - int skip = (currentPage - 1) * menu.globalConfig.commandsPerPage; - List pageSlice = allCommands.subList(skip, Math.min(skip + menu.globalConfig.commandsPerPage, allCommands.size())); + int skip = (currentPage - 1) * menu.secretsConfig.pagination.commandsPerPage; + List pageSlice = allCommands.subList(skip, Math.min(skip + menu.secretsConfig.pagination.commandsPerPage, allCommands.size())); var local = context.locale(); var grid = new MenuGrid() 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..084d9a2e 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/HelpMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/HelpMenu.java @@ -12,8 +12,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; import org.xcore.plugin.ui.MenuService; @@ -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(TomlSecretsConfig secretsConfig, SessionService sessionService, Provider cloud, MenuService menuService) { + super(secretsConfig, 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..0769b3b0 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.TomlSecretsConfig; +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, TomlSecretsConfig secretsConfig, SessionService sessionService, BuildInfo buildInfo, MenuService menuService, Provider map, Provider event, Provider help, Provider player) { - super(config, globalConfig, sessionService); + super(secretsConfig, 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") @@ -117,11 +119,11 @@ private void openRoutedFromMain(MenuRenderContext context, Runnable act private final class InformationFlow extends BaseMenuFlow { InformationFlow() { super(ROUTE_INFORMATION, NoState.class); - action("discord", ctx -> openUrl(ctx.session(), globalConfig.discordUrl)); - action("github", ctx -> openUrl(ctx.session(), globalConfig.githubUrl)); - action("donatello", ctx -> openUrl(ctx.session(), globalConfig.donatelloUrl)); - action("weblate", ctx -> openUrl(ctx.session(), globalConfig.weblateUrl)); - action("discord-red-vs-blue", ctx -> openUrl(ctx.session(), globalConfig.discordRedVSBlueUrl)); + action("discord", ctx -> openUrl(ctx.session(), secretsConfig.externalLinks.discordUrl)); + action("github", ctx -> openUrl(ctx.session(), secretsConfig.externalLinks.githubUrl)); + action("donatello", ctx -> openUrl(ctx.session(), secretsConfig.externalLinks.donatelloUrl)); + action("weblate", ctx -> openUrl(ctx.session(), secretsConfig.externalLinks.weblateUrl)); + action("discord-red-vs-blue", ctx -> openUrl(ctx.session(), secretsConfig.externalLinks.discordRedVSBlueUrl)); } @Override @@ -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..7890d503 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; @@ -62,11 +62,11 @@ static final class MapsFlow extends BaseMenuFlow { Session session = ctx.session(); int page = ctx.route().intParam("page", 1); Seq availableMaps = mapService.getAvailableMaps(); - var pagination = CustomGatherers.calculatePagination(availableMaps.size, menu.globalConfig.mapsPerPage); + var pagination = CustomGatherers.calculatePagination(availableMaps.size, menu.secretsConfig.pagination.mapsPerPage); int validPage = pagination.clampPage(page); String gameMode = state.rules.mode().name(); List pageMaps = SeqStream.of(availableMaps) - .gather(CustomGatherers.page(menu.globalConfig.mapsPerPage, validPage)) + .gather(CustomGatherers.page(menu.secretsConfig.pagination.mapsPerPage, validPage)) .flatMap(List::stream) .toList(); if (index >= 0 && index < pageMaps.size()) { @@ -89,7 +89,7 @@ public MenuScreen render(MenuRenderContext context) { Session session = context.session(); int page = context.route().intParam("page", 1); Seq availableMaps = mapService.getAvailableMaps(); - var pagination = CustomGatherers.calculatePagination(availableMaps.size, menu.globalConfig.mapsPerPage); + var pagination = CustomGatherers.calculatePagination(availableMaps.size, menu.secretsConfig.pagination.mapsPerPage); if (availableMaps.isEmpty()) { session.locale().send("empty"); @@ -102,7 +102,7 @@ public MenuScreen render(MenuRenderContext context) { int validPage = pagination.clampPage(page); List pageMaps = SeqStream.of(availableMaps) - .gather(CustomGatherers.page(menu.globalConfig.mapsPerPage, validPage)) + .gather(CustomGatherers.page(menu.secretsConfig.pagination.mapsPerPage, validPage)) .flatMap(List::stream) .toList(); @@ -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..87d49930 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.TomlSecretsConfig; +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, TomlSecretsConfig secretsConfig, SessionService sessionService, MapDataRepository mapDataRepository, EventDataRepository eventDataRepository, MapService mapService, Provider eventMenu, MenuService menuService) { - super(config, globalConfig, sessionService); + super(secretsConfig, 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..dc76b8fc 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/Menu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/Menu.java @@ -3,8 +3,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; @@ -12,13 +11,11 @@ import static com.ospx.flubundle.Bundle.args; public class Menu { - protected final Config config; - protected final GlobalConfig globalConfig; + protected final TomlSecretsConfig secretsConfig; protected final SessionService sessionService; - public Menu(Config config, GlobalConfig globalConfig, SessionService sessionService) { - this.config = config; - this.globalConfig = globalConfig; + public Menu(TomlSecretsConfig secretsConfig, SessionService sessionService) { + this.secretsConfig = secretsConfig; this.sessionService = sessionService; } diff --git a/src/main/java/org/xcore/plugin/ui/menu/MessageFlows.java b/src/main/java/org/xcore/plugin/ui/menu/MessageFlows.java index 36f77590..54372a67 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/MessageFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/MessageFlows.java @@ -76,7 +76,7 @@ public MenuScreen render(MenuRenderContext context) { int requestedPage = Math.max(1, context.state().page); long total = privateMessageService.countInbox(session.data.uuid); - var pagination = CustomGatherers.calculatePagination(total, menu.globalConfig.privateMessagesPerPage); + var pagination = CustomGatherers.calculatePagination(total, menu.secretsConfig.pagination.privateMessagesPerPage); int validPage = pagination.totalPages() <= 0 ? 1 : pagination.clampPage(requestedPage); context.state().page = validPage; java.util.List messages = privateMessageService.inbox(session.data.uuid, validPage); @@ -141,7 +141,7 @@ static final class DetailsFlow extends BaseMenuFlow { PROMPT_REPLY, ctx.session().locale().t("private-message-reply-title"), ctx.session().locale().t("private-message-reply-message", args("pid", view.message.fromPid)), - menu.globalConfig.privateMessageMaxLength, + menu.secretsConfig.messages.privateMessages.maxLength, "", false )); @@ -273,11 +273,11 @@ public MenuScreen render(MenuRenderContext context) { int requestedPage = Math.max(1, context.state().page); java.util.List blockedPlayers = privateMessageService.listBlocked(session); - var pagination = CustomGatherers.calculatePagination(blockedPlayers.size(), menu.globalConfig.privateMessagesPerPage); + var pagination = CustomGatherers.calculatePagination(blockedPlayers.size(), menu.secretsConfig.pagination.privateMessagesPerPage); int validPage = pagination.totalPages() <= 0 ? 1 : pagination.clampPage(requestedPage); context.state().page = validPage; - int start = (validPage - 1) * menu.globalConfig.privateMessagesPerPage; - int end = Math.min(start + menu.globalConfig.privateMessagesPerPage, blockedPlayers.size()); + int start = (validPage - 1) * menu.secretsConfig.pagination.privateMessagesPerPage; + int end = Math.min(start + menu.secretsConfig.pagination.privateMessagesPerPage, blockedPlayers.size()); java.util.List pageItems = start >= blockedPlayers.size() ? java.util.List.of() : blockedPlayers.subList(start, end); var grid = new MenuGrid(); @@ -362,7 +362,7 @@ private static void handleComposeTarget(MessageMenu menu, PROMPT_COMPOSE_BODY, session.locale().t("private-message-compose-body-title"), session.locale().t("private-message-compose-body-message", args("pid", "#" + targetPid)), - menu.globalConfig.privateMessageMaxLength, + menu.secretsConfig.messages.privateMessages.maxLength, "", false), message -> { 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..819e9896 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/MessageMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/MessageMenu.java @@ -4,8 +4,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.service.PrivateMessageService; import org.xcore.plugin.session.Session; import org.xcore.plugin.session.SessionService; @@ -19,12 +18,11 @@ public class MessageMenu extends Menu { private final MenuService menuService; @Inject - public MessageMenu(Config config, - GlobalConfig globalConfig, + public MessageMenu(TomlSecretsConfig secretsConfig, SessionService sessionService, PrivateMessageService privateMessageService, MenuService menuService) { - super(config, globalConfig, sessionService); + super(secretsConfig, 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..be4392d1 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/PlayerMenu.java @@ -4,8 +4,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.database.repository.GameDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.service.PlayerDisplayService; @@ -23,8 +22,7 @@ public class PlayerMenu extends Menu { private final MenuService menuService; @Inject - public PlayerMenu(Config config, - GlobalConfig globalConfig, + public PlayerMenu(TomlSecretsConfig secretsConfig, 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(secretsConfig, sessionService); this.bundle = bundle; this.profileSettings = profileSettings; this.menuService = menuService; diff --git a/src/main/java/org/xcore/plugin/ui/menu/PlayerProfileFlows.java b/src/main/java/org/xcore/plugin/ui/menu/PlayerProfileFlows.java index 07607a56..bdcc1787 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/PlayerProfileFlows.java +++ b/src/main/java/org/xcore/plugin/ui/menu/PlayerProfileFlows.java @@ -165,7 +165,7 @@ static final class PlayersFlow extends BaseMenuFlow { actionPrefix("select:", (ctx, indexStr) -> { int index = Integer.parseInt(indexStr); List onlinePlayers = getFilteredOnlinePlayers(ctx.session(), sessionService, playerDisplayService); - int perPage = menu.globalConfig.eventsPerPage; + int perPage = menu.secretsConfig.pagination.eventsPerPage; var pagination = CustomGatherers.calculatePagination(onlinePlayers.size(), perPage); int validPage = pagination.totalPages() <= 0 ? 1 : pagination.clampPage(ctx.state().page); int skip = (validPage - 1) * perPage; @@ -197,7 +197,7 @@ public MenuScreen render(MenuRenderContext context) { List onlinePlayers = getFilteredOnlinePlayers(session, sessionService, playerDisplayService); int totalPlayers = onlinePlayers.size(); - int perPage = menu.globalConfig.eventsPerPage; + int perPage = menu.secretsConfig.pagination.eventsPerPage; var pagination = CustomGatherers.calculatePagination(totalPlayers, perPage); int totalPages = Math.max(1, pagination.totalPages()); int validPage = pagination.totalPages() <= 0 ? 1 : pagination.clampPage(state.page); 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..62e0ae57 100644 --- a/src/main/java/org/xcore/plugin/ui/menu/TopMenu.java +++ b/src/main/java/org/xcore/plugin/ui/menu/TopMenu.java @@ -3,8 +3,7 @@ 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.config.TomlSecretsConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.LeaderboardCursor; import org.xcore.plugin.model.PlayerData; @@ -45,13 +44,12 @@ public class TopMenu extends Menu { private final MenuService menuService; @Inject - public TopMenu(Config config, - GlobalConfig globalConfig, + public TopMenu(TomlSecretsConfig secretsConfig, SessionService sessionService, MenuService menuService, TopMenuService topMenuService, PlayerMenu playerMenu) { - super(config, globalConfig, sessionService); + super(secretsConfig, sessionService); this.menuService = menuService; this.topMenuService = topMenuService; this.playerMenu = playerMenu; diff --git a/src/main/java/org/xcore/plugin/vote/VoteEvent.java b/src/main/java/org/xcore/plugin/vote/VoteEvent.java index a1d27a77..e49731cd 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteEvent.java +++ b/src/main/java/org/xcore/plugin/vote/VoteEvent.java @@ -4,7 +4,7 @@ import io.avaje.inject.Assisted; import jakarta.inject.Inject; import mindustry.gen.Player; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.model.EventData; import org.xcore.plugin.session.SessionService; @@ -23,10 +23,10 @@ public class VoteEvent extends VoteSession { public VoteEvent( @Assisted EventData target, EventDataRepository eventDataRepository, - GlobalConfig globalConfig, + TomlSecretsConfig secretsConfig, SessionService sessionService, VoteService voteService) { - super(globalConfig); + super(secretsConfig); this.target = target; this.eventDataRepository = eventDataRepository; this.sessionService = sessionService; diff --git a/src/main/java/org/xcore/plugin/vote/VoteKick.java b/src/main/java/org/xcore/plugin/vote/VoteKick.java index 46b2289a..54b5e255 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteKick.java +++ b/src/main/java/org/xcore/plugin/vote/VoteKick.java @@ -12,8 +12,8 @@ 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.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.localization.Localization; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.SessionService; @@ -42,8 +42,8 @@ public class VoteKick extends VoteSession { private final SessionService sessionService; private final NetworkService network; private final VoteService voteService; - private final Config config; - private final GlobalConfig globalConfig; + private final TomlXcoreConfig config; + private final TomlSecretsConfig secretsConfig; @Inject public VoteKick( @@ -55,9 +55,9 @@ public VoteKick( SessionService sessionService, NetworkService network, VoteService voteService, - Config config, - GlobalConfig globalConfig) { - super(globalConfig); + TomlXcoreConfig config, + TomlSecretsConfig secretsConfig) { + super(secretsConfig); this.starter = starter; this.target = target; this.reason = reason; @@ -66,7 +66,7 @@ public VoteKick( this.network = network; this.voteService = voteService; this.config = config; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; } public static void setOnKick(Cons onKick) { @@ -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() ); } @@ -204,14 +204,14 @@ public void success() { stop(); var bundleArgs = args( "target", target.coloredName(), - "minutes", globalConfig.voteKickBanDurationMinutes); + "minutes", secretsConfig.moderation.votekick.banDurationMinutes); sessionService.broadcast("votekick-success", bundleArgs); - target.kick(Packets.KickReason.vote, (long) globalConfig.voteKickBanDurationMinutes * 60 * 1000); + target.kick(Packets.KickReason.vote, (long) secretsConfig.moderation.votekick.banDurationMinutes * 60 * 1000); 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/main/java/org/xcore/plugin/vote/VoteNewWave.java b/src/main/java/org/xcore/plugin/vote/VoteNewWave.java index 40e6abdc..1cb1b093 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteNewWave.java +++ b/src/main/java/org/xcore/plugin/vote/VoteNewWave.java @@ -5,7 +5,7 @@ import jakarta.inject.Inject; import mindustry.Vars; import mindustry.gen.Player; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.session.SessionService; import static com.ospx.flubundle.Bundle.args; @@ -22,11 +22,11 @@ public class VoteNewWave extends VoteSession { @Inject public VoteNewWave( @Assisted int sourceWave, - GlobalConfig globalConfig, + TomlSecretsConfig secretsConfig, SessionService sessionService, VoteService voteService ) { - super(globalConfig); + super(secretsConfig); this.sourceWave = sourceWave; this.sessionService = sessionService; this.voteService = voteService; diff --git a/src/main/java/org/xcore/plugin/vote/VoteRtv.java b/src/main/java/org/xcore/plugin/vote/VoteRtv.java index ead98585..31536cec 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteRtv.java +++ b/src/main/java/org/xcore/plugin/vote/VoteRtv.java @@ -8,7 +8,7 @@ import mindustry.game.Gamemode; import mindustry.gen.Player; import mindustry.maps.Map; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.service.GameStateService; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.model.MapData; @@ -28,7 +28,7 @@ public class VoteRtv extends VoteSession { public final boolean isManualSelection; private final MapDataRepository mapDataRepository; - private final GlobalConfig globalConfig; + private final TomlSecretsConfig secretsConfig; private final SessionService sessionService; private final VoteService voteService; private final GameStateService gameStateService; @@ -40,16 +40,16 @@ public VoteRtv( @Assisted boolean isManualSelection, MapDataRepository mapDataRepository, - GlobalConfig globalConfig, + TomlSecretsConfig secretsConfig, SessionService sessionService, VoteService voteService, GameStateService gameStateService, GameDataService gameDataService) { - super(globalConfig); + super(secretsConfig); this.target = target; this.isManualSelection = isManualSelection; this.mapDataRepository = mapDataRepository; - this.globalConfig = globalConfig; + this.secretsConfig = secretsConfig; this.sessionService = sessionService; this.voteService = voteService; this.gameStateService = gameStateService; @@ -81,7 +81,7 @@ public void success() { stop(); sessionService.broadcast("rtv-success", args( "mapName", target.name(), - "mapLoadDelay", globalConfig.mapSwitchDelaySeconds)); + "mapLoadDelay", secretsConfig.maps.voting.switchDelaySeconds)); if (state.map != null && !state.isMenu()) { String currentMapName = state.map.plainName(); @@ -118,7 +118,7 @@ public void success() { Groups.player.each(gameDataService::addPlayer); }); }, - globalConfig.mapSwitchDelaySeconds); + secretsConfig.maps.voting.switchDelaySeconds); } @Override diff --git a/src/main/java/org/xcore/plugin/vote/VoteSession.java b/src/main/java/org/xcore/plugin/vote/VoteSession.java index 32b187ba..a9971b0c 100644 --- a/src/main/java/org/xcore/plugin/vote/VoteSession.java +++ b/src/main/java/org/xcore/plugin/vote/VoteSession.java @@ -5,7 +5,7 @@ import arc.util.Timer; import mindustry.gen.Groups; import mindustry.gen.Player; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import static arc.Core.app; @@ -14,8 +14,8 @@ public abstract class VoteSession { public final IntIntMap voted = new IntIntMap(); public final Timer.Task end; - public VoteSession(GlobalConfig config) { - end = Timer.schedule(this::fail, config.voteDurationSeconds); + public VoteSession(TomlSecretsConfig secretsConfig) { + end = Timer.schedule(this::fail, secretsConfig.moderation.votekick.voteDurationSeconds); } public void vote(Player player, int sign) { diff --git a/src/test/java/org/xcore/plugin/cloud/CloudCommandPipelineIntegrationTest.java b/src/test/java/org/xcore/plugin/cloud/CloudCommandPipelineIntegrationTest.java index ae66b510..05a92339 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.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.SecurityService; import org.xcore.plugin.session.SessionService; @@ -38,9 +38,9 @@ void setUp() { var securityService = mock(SecurityService.class); 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 secretsConfig = new TomlSecretsConfig(); + 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"); @@ -62,7 +62,7 @@ void setUp() { CloudGuardConfigurer guardConfigurer = new CloudGuardConfigurer( () -> securityService, () -> sessionService, - globalConfig, + secretsConfig, policy ); 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..cfccacdc --- /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.TomlSecretsConfig; +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 TomlSecretsConfig(), + 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..2eba56e5 --- /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.TomlSecretsConfig; +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 TomlSecretsConfig(), + 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 TomlSecretsConfig(), + 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..c82fa968 --- /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.TomlSecretsConfig; +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 TomlSecretsConfig(), + 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 cb99770f..811eba12 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,35 +1,247 @@ 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; 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; 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 TomlXcoreConfig(); + 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(TomlXcoreConfig.class); + verify(tomlStore).write(configCaptor.capture()); + assertThat(configCaptor.getValue().server.playerLimit).isEqualTo(64); + assertThat(config.server.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 TomlXcoreConfig(); + 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(TomlXcoreConfig.class); + verify(tomlStore).write(configCaptor.capture()); + assertThat(configCaptor.getValue().transport.redis.url).isEqualTo("redis://example:6379"); + assertThat(config.transport.redis.url).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 TomlXcoreConfig(); + 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(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 + @DisplayName("xconfigEdit supports array-style translation pipeline updates") + void xconfigEdit_supportsJsonArrayTranslationPipeline() { + var repository = mock(PlayerDataRepository.class); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new TomlXcoreConfig(); + 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(TomlXcoreConfig.class); + verify(tomlStore).write(configCaptor.capture()); + assertThat(configCaptor.getValue().translation.pipeline) + .containsExactly("google", "openai"); + assertThat(config.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 TomlXcoreConfig(); + 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(TomlXcoreConfig.class)); + assertThat(config.server.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 TomlXcoreConfig(); + 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(TomlXcoreConfig.class)); + assertThat(config.server.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 TomlXcoreConfig(); + 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(TomlXcoreConfig.class)); + assertThat(config.server.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 TomlXcoreConfig(); + 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(TomlXcoreConfig.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 TomlXcoreConfig(); + 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(TomlXcoreConfig.class)); + verify(pathEditor, never()).update(any(TomlXcoreConfig.class), any(String.class), any(String.class)); + assertThat(config.runtime.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 TomlXcoreConfig(); + 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(TomlXcoreConfig.class)); + verify(pathEditor, never()).update(any(TomlXcoreConfig.class), any(String.class), any(String.class)); + assertThat(config.runtime.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 config = new Config(); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var config = new TomlXcoreConfig(); 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 +252,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 +264,36 @@ 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 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); + var pathEditor = new ServerLocalConfigPathEditor(gson); + var tomlRenderer = mock(ServerLocalConfigTomlRenderer.class); + when(tomlRenderer.render(config)).thenReturn(""" + version = 1 + + [server] + name = \"alpha\" + player_limit = 64 + + [transport.redis] + url = \"redis://example:6379\" + """); + + var controller = new DataController(repository, config, gson, find, pathEditor, tomlRenderer, tomlStore); + controller.xconfigShow(sender); + + verify(tomlRenderer).render(config); + 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 e6c07940..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,7 +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; @@ -33,8 +34,9 @@ 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(); var sender = mock(XCoreSender.class); @@ -44,8 +46,8 @@ void gcmdParsesCommaSeparatedTargets() { pluginState, sessionService, auditService, - config, - configFile, + serverLocalConfig, + tomlStore, gson ); @@ -68,8 +70,9 @@ 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(); var sender = mock(XCoreSender.class); @@ -79,8 +82,8 @@ void gcmdFallsBackToAllServers() { pluginState, sessionService, auditService, - config, - configFile, + serverLocalConfig, + tomlStore, gson ); @@ -103,8 +106,9 @@ 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(); var sender = mock(XCoreSender.class); @@ -114,8 +118,8 @@ void gcmdPreservesExclusionMode() { pluginState, sessionService, auditService, - config, - configFile, + serverLocalConfig, + tomlStore, gson ); @@ -138,8 +142,9 @@ 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(); var sender = mock(XCoreSender.class); @@ -149,14 +154,14 @@ void disableCmd_normalizesCommandAndPersistsConfig() { pluginState, sessionService, auditService, - config, - configFile, + 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()); } @@ -168,8 +173,9 @@ 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(); var sender = mock(XCoreSender.class); @@ -179,14 +185,14 @@ void disableCmd_doesNotPersistProtectedCommands() { pluginState, sessionService, auditService, - config, - configFile, + serverLocalConfig, + tomlStore, gson ); controller.disableCmd(sender, "disable-cmd nested"); - assertThat(config.disabledCommands).isEmpty(); + assertThat(serverLocalConfig.runtime.disabledCommands).isEmpty(); verify(configFile, never()).writeString(anyString()); } @@ -198,9 +204,10 @@ 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(); var sender = mock(XCoreSender.class); @@ -210,14 +217,14 @@ void enableFeature_removesDisabledFeatureAndPersistsConfig() { pluginState, sessionService, auditService, - config, - configFile, + serverLocalConfig, + tomlStore, gson ); controller.enableFeature(sender, "rtv"); - assertThat(config.disabledFeatures).doesNotContain("rtv"); + assertThat(serverLocalConfig.runtime.disabledFeatures).doesNotContain("rtv"); verify(configFile).writeString(anyString()); } @@ -230,8 +237,10 @@ 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(); var sender = mock(XCoreSender.class); @@ -244,8 +253,8 @@ void deleteBots_invalidatesTopCacheWhenPlayersAreRemoved() { sessionService, auditService, topMenuCacheService, - config, - configFile, + serverLocalConfig, + tomlStore, gson ); @@ -253,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..e042ab97 --- /dev/null +++ b/src/test/java/org/xcore/plugin/command/controller/server/RuntimeToggleConfigServiceTest.java @@ -0,0 +1,80 @@ +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +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)); + } + + @Test + @DisplayName("disable rolls back runtime command toggle when save fails") + void disable_rollsBackRuntimeCommandToggleWhenSaveFails() { + var config = new TomlXcoreConfig(); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var service = new RuntimeToggleConfigService(config, tomlStore); + doThrow(new IllegalStateException("disk full")) + .when(tomlStore) + .write(any(TomlXcoreConfig.class)); + + assertThatThrownBy(() -> service.disable(RuntimeToggleConfigService.ToggleTarget.COMMAND, "help")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("disk full"); + + assertThat(config.runtime.disabledCommands).doesNotContain("help"); + } + + @Test + @DisplayName("enable rolls back runtime feature toggle when save fails") + void enable_rollsBackRuntimeFeatureToggleWhenSaveFails() { + var config = new TomlXcoreConfig(); + config.runtime.disabledFeatures.add("rtv"); + var tomlStore = mock(ServerLocalConfigTomlStore.class); + var service = new RuntimeToggleConfigService(config, tomlStore); + doThrow(new IllegalStateException("disk full")) + .when(tomlStore) + .write(any(TomlXcoreConfig.class)); + + assertThatThrownBy(() -> service.enable(RuntimeToggleConfigService.ToggleTarget.FEATURE, "rtv")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("disk full"); + + assertThat(config.runtime.disabledFeatures).contains("rtv"); + } +} 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..30dcfb0d 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.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationMetricsService; import java.util.LinkedHashMap; @@ -20,18 +20,18 @@ 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(); - globalConfig.translationProviders = new LinkedHashMap<>(); - GlobalConfig.TranslationProviderConfig nvidia = new GlobalConfig.TranslationProviderConfig(); + TomlSecretsConfig secretsConfig = new TomlSecretsConfig(); + secretsConfig.translation.providers = new LinkedHashMap<>(); + TomlSecretsConfig.TranslationSection.ProviderConfig nvidia = new TomlSecretsConfig.TranslationSection.ProviderConfig(); nvidia.type = "openai"; - globalConfig.translationProviders.put("nvidia-mistral-small", nvidia); - GlobalConfig.TranslationProviderConfig google = new GlobalConfig.TranslationProviderConfig(); + secretsConfig.translation.providers.put("nvidia-mistral-small", nvidia); + TomlSecretsConfig.TranslationSection.ProviderConfig google = new TomlSecretsConfig.TranslationSection.ProviderConfig(); google.type = "google"; - globalConfig.translationProviders.put("google", google); + secretsConfig.translation.providers.put("google", google); TranslationMetricsService translationMetricsService = mock(TranslationMetricsService.class); when(translationMetricsService.readGlobalTotals()).thenReturn(Map.of("requests_total", "5")); @@ -41,7 +41,7 @@ void translationStats_queriesGlobalAndPerProviderMetrics() { when(translationMetricsService.readProviderTotals("google")).thenReturn(Map.of("attempts_total", "2")); when(translationMetricsService.readCurrentMinuteProvider("google")).thenReturn(Map.of("attempts_total", "1")); - TranslationStatsController controller = new TranslationStatsController(config, globalConfig, translationMetricsService); + TranslationStatsController controller = new TranslationStatsController(config, secretsConfig, translationMetricsService); assertThatNoException().isThrownBy(() -> controller.translationStats(mock(XCoreSender.class))); diff --git a/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java b/src/test/java/org/xcore/plugin/config/ConfigFactoryTest.java index e5b0668a..0cdd0079 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,60 +32,102 @@ 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("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(); 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("server-local 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\": \"event\", - \"disabled_commands\": null, - \"disabled_features\": null, - \"translation\": null + "server": "json-server" } """); + ConfigFactory factory = new ConfigFactory(); // Act - Config config = factory.config(prettyGson); + TomlXcoreConfig serverLocalConfig = factory.serverLocalConfig(prettyGson); + Config config = factory.config(serverLocalConfig); // Assert - assertThat(config.server).isEqualTo("event"); + assertThat(serverLocalConfig.server.name).isEqualTo("toml-server"); + assertThat(config.server).isEqualTo("toml-server"); + } + + @Test + @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"); + write(jsonPath, """ + { + "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 + 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(); 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(); @@ -92,17 +135,91 @@ void globalConfig_createsMissingSecretsFileInConfiguredDirectoryBeforeFailingVal ConfigFactory factory = new ConfigFactory(); // Act + Assert - assertThatThrownBy(() -> factory.globalConfig(config, prettyGson)) + assertThatThrownBy(() -> factory.globalConfig(config, factory.tomlSecretsConfig(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 + TomlSecretsConfig tomlSecretsConfig = factory.tomlSecretsConfig(config, prettyGson); + GlobalConfig globalConfig = factory.globalConfig(config, tomlSecretsConfig); + + // Assert + assertThat(tomlSecretsConfig.database.mongoConnectionString).isEqualTo("mongodb://toml:27017"); + assertThat(tomlSecretsConfig.database.name).isEqualTo("toml-db"); + 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 + TomlSecretsConfig tomlSecretsConfig = factory.tomlSecretsConfig(config, prettyGson); + GlobalConfig globalConfig = factory.globalConfig(config, tomlSecretsConfig); + + // Assert + assertThat(tomlSecretsConfig.database.mongoConnectionString).isEqualTo("mongodb://legacy:27017"); + assertThat(tomlSecretsConfig.database.name).isEqualTo("legacy-db"); + 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 +227,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(); @@ -124,9 +243,12 @@ void globalConfig_readsValidSecretsFromConfiguredDirectoryAndNormalizesProviders ConfigFactory factory = new ConfigFactory(); // Act - GlobalConfig globalConfig = factory.globalConfig(config, prettyGson); + TomlSecretsConfig tomlSecretsConfig = factory.tomlSecretsConfig(config, prettyGson); + GlobalConfig globalConfig = factory.globalConfig(config, tomlSecretsConfig); // Assert + assertThat(tomlSecretsConfig.database.mongoConnectionString).isEqualTo("mongodb://localhost:27017"); + assertThat(tomlSecretsConfig.database.name).isEqualTo("xcore"); assertThat(globalConfig.mongoConnectionString).isEqualTo("mongodb://localhost:27017"); assertThat(globalConfig.databaseName).isEqualTo("xcore"); assertThat(globalConfig.translationProviders).containsOnlyKeys("google"); 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..fe6d2dbe --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ConfigTomlLoaderTest.java @@ -0,0 +1,310 @@ +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.name).isEqualTo("test-server"); + assertThat(result.config.server.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(Files.readString(tempDir.resolve("xcore.toml"))) + .contains("[server]") + .contains("name = \"legacy-server\"") + .doesNotContain("server.name ="); + 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); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.DEFAULT_TEMPLATE); + assertThat(result.file.name()).isEqualTo("xcore.toml"); + assertThat(result.file.exists()).isTrue(); + assertThat(result.config.server.name).isEqualTo("server"); + assertThat(result.config.server.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.name).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("loadTomlSecretsConfig returns TOML source when secrets.toml exists") + void loadTomlSecretsConfig_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.loadTomlSecretsConfig(tempDir.toString(), gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); + assertThat(result.file.name()).isEqualTo("secrets.toml"); + assertThat(result.config.database.mongoConnectionString).isEqualTo("mongodb://toml:27017"); + assertThat(result.config.database.name).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(Files.readString(tempDir.resolve("secrets.toml"))) + .contains("[database]") + .contains("mongo_connection_string = \"mongodb://json:27017\"") + .doesNotContain("database.mongo_connection_string ="); + assertThat(result.config.mongoConnectionString).isEqualTo("mongodb://json:27017"); + assertThat(result.config.databaseName).isEqualTo("json-db"); + } + + @Test + @DisplayName("loadTomlSecretsConfig returns MIGRATED source when only secrets.json exists") + void loadTomlSecretsConfig_returnsMigratedSource_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.loadTomlSecretsConfig(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(Files.readString(tempDir.resolve("secrets.toml"))) + .contains("[database]") + .contains("mongo_connection_string = \"mongodb://json:27017\"") + .doesNotContain("database.mongo_connection_string ="); + assertThat(result.config.database.mongoConnectionString).isEqualTo("mongodb://json:27017"); + assertThat(result.config.database.name).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("loadTomlSecretsConfig returns DEFAULT_TEMPLATE source and creates file when neither exists") + void loadTomlSecretsConfig_returnsDefaultTemplateSource_whenNeitherExists() { + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadTomlSecretsConfig(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.database.mongoConnectionString).isEqualTo(""); + assertThat(result.config.database.name).isEqualTo(""); + } + + @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("loadTomlSecretsConfig prefers TOML over legacy JSON when both exist") + void loadTomlSecretsConfig_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.loadTomlSecretsConfig(tempDir.toString(), gson); + + assertThat(result.source).isEqualTo(ConfigTomlLoader.Source.TOML); + assertThat(result.config.database.mongoConnectionString).isEqualTo("mongodb://toml:27017"); + assertThat(result.config.database.name).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..6364e50b --- /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).isNull(); // blankToNull + assertThat(google.model).isNull(); // blankToNull + 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..47aef83b --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigPathEditorTest.java @@ -0,0 +1,121 @@ +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 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() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "playerLimit", "64"); + + assertThat(updated).isNotNull(); + assertThat(updated.server.playerLimit).isEqualTo(64); + } + + @Test + @DisplayName("update supports canonical TOML dotted paths") + void update_supportsCanonicalTomlDottedPaths() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "server.player_limit", "48"); + + assertThat(updated).isNotNull(); + assertThat(updated.server.playerLimit).isEqualTo(48); + } + + @Test + @DisplayName("update supports nested transport redis dotted paths") + void update_supportsNestedTransportRedisDottedPaths() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "transport.redis.url", "redis://example:6379"); + + assertThat(updated).isNotNull(); + assertThat(updated.transport.redis.url).isEqualTo("redis://example:6379"); + } + + @Test + @DisplayName("update parses comma separated translation pipeline and drops blanks") + void update_parsesCommaSeparatedTranslationPipelineAndDropsBlanks() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "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() { + 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 on TomlXcoreConfig") + void update_returnsNullForUnsupportedPathOnTomlXcoreConfig() { + TomlXcoreConfig updated = editor.update(new TomlXcoreConfig(), "missing.path", "value"); + + assertThat(updated).isNull(); + } + + @Test + @DisplayName("update throws friendly exception for invalid boolean value") + void update_throwsFriendlyExceptionForInvalidBooleanValue() { + assertThatThrownBy(() -> editor.update(new TomlXcoreConfig(), "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 TomlXcoreConfig(), "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 TomlXcoreConfig(), "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 TomlXcoreConfig(), "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/ServerLocalConfigTomlRendererTest.java b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlRendererTest.java new file mode 100644 index 00000000..da86fdbb --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlRendererTest.java @@ -0,0 +1,39 @@ +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]") + .contains("name = \"alpha\"") + .contains("player_limit = 64") + .contains("[transport.redis]") + .contains("url = \"redis://example:6379\"") + .doesNotContain("server.name =") + .doesNotContain("transport.redis.url ="); + } + + @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 new file mode 100644 index 00000000..9c923501 --- /dev/null +++ b/src/test/java/org/xcore/plugin/config/ServerLocalConfigTomlStoreTest.java @@ -0,0 +1,111 @@ +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 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); + + 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); + + assertThat(tomlPath).exists(); + String written = Files.readString(tomlPath); + assertThat(written).isNotBlank(); + assertThat(written) + .contains("[server]") + .contains("name = \"test-server\"") + .contains("[runtime]") + .contains("disabled_commands =") + .doesNotContain("server.name =") + .doesNotContain("runtime.disabled_commands ="); + + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( + new Fi(tempDir.toFile()), + new SerializationFactory().prettyGson() + ); + 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 TomlXcoreConfig before persisting") + void write_normalizesTomlXcoreConfigBeforePersisting() { + Path tomlPath = tempDir.resolve("xcore.toml"); + Fi tomlFile = new Fi(tomlPath.toFile()); + ServerLocalConfigTomlStore store = new ServerLocalConfigTomlStore(tomlFile); + + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = null; + config.runtime.disabledCommands = null; + + store.write(config); + + ConfigTomlLoader.LoadResult result = ConfigTomlLoader.loadXcoreConfig( + new Fi(tempDir.toFile()), + new SerializationFactory().prettyGson() + ); + assertThat(result.config.server.name).isEqualTo("server"); + assertThat(result.config.runtime.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((TomlXcoreConfig) 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..5aaf80dd --- /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(""); + assertThat(google.model).isEqualTo(""); + 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(""); + assertThat(normalized.model).isEqualTo(""); + assertThat(normalized.timeoutSeconds).isEqualTo(15); + assertThat(normalized.maxRetries).isEqualTo(1); + assertThat(normalized.supportedLanguages).isNotNull().isEmpty(); + } + + @Test + @DisplayName("normalize preserves blank optional strings; blank-to-null conversion happens in mapper") + void normalize_preservesBlankOptionalStrings_forLaterMapperConversion() { + 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(); + } +} diff --git a/src/test/java/org/xcore/plugin/database/repository/AuditRecordRepositoryLogicTest.java b/src/test/java/org/xcore/plugin/database/repository/AuditRecordRepositoryLogicTest.java index 75439b99..f3d7c84b 100644 --- a/src/test/java/org/xcore/plugin/database/repository/AuditRecordRepositoryLogicTest.java +++ b/src/test/java/org/xcore/plugin/database/repository/AuditRecordRepositoryLogicTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.AuditCursor; import org.xcore.plugin.model.AuditRecord; @@ -27,7 +27,7 @@ void setUp() { MongoCollection collection = mock(MongoCollection.class); when(database.getCollection("moderation_audit", AuditRecord.class)).thenReturn(collection); - repository = new AuditRecordRepository(database, new GlobalConfig()); + repository = new AuditRecordRepository(database, new TomlSecretsConfig()); } @Test diff --git a/src/test/java/org/xcore/plugin/database/repository/EventDataRepositoryLogicTest.java b/src/test/java/org/xcore/plugin/database/repository/EventDataRepositoryLogicTest.java index 62fe289b..b2d30f6d 100644 --- a/src/test/java/org/xcore/plugin/database/repository/EventDataRepositoryLogicTest.java +++ b/src/test/java/org/xcore/plugin/database/repository/EventDataRepositoryLogicTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.xcore.plugin.common.StatusEnum; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.model.EventData; import java.util.Map; @@ -29,7 +29,7 @@ void setUp() { MongoCollection collection = mock(MongoCollection.class); when(database.getCollection("events", EventData.class)).thenReturn(collection); - repository = new EventDataRepository(database, new GlobalConfig()); + repository = new EventDataRepository(database, new TomlSecretsConfig()); } @Test 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..694f3ba8 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.TomlSecretsConfig; +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,16 +74,16 @@ void onPlayerJoin_persistsNicknameWithChangedIp_andRevokesUnconfirmedAdmin() { DiscordAdminAccessService discordAdminAccessService = mock(DiscordAdminAccessService.class); ObserverService observerService = mock(ObserverService.class); - Config config = new Config(); - config.server = "mini-pvp"; - GlobalConfig globalConfig = new GlobalConfig(); + TomlXcoreConfig config = new TomlXcoreConfig(); + config.server.name = "mini-pvp"; + TomlSecretsConfig secretsConfig = new TomlSecretsConfig(); ConnectionHandler handler = new ConnectionHandler( sessionService, adminDataRepository, networkService, config, - globalConfig, + secretsConfig, voteService, privateMessageService, playerDisplayService, 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..35dbbddf 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,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.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 +68,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 +103,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 +138,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); @@ -153,7 +152,7 @@ void playerSessionCommands_updateSessionStateAndRefreshDisplay() { playerData.activeBadge = ""; playerData.badgeSymbolColorMode = "default"; Session session = new Session( - mock(GlobalConfig.class), + mock(org.xcore.plugin.config.TomlSecretsConfig.class), mock(Bundle.class), mock(MenuService.class), mock(PlayerDataRepository.class), @@ -185,8 +184,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); @@ -198,7 +197,7 @@ void badgeInventoryCommand_updatesUnlockedBadgesAndRefreshesDisplay() { playerData.activeBadge = "old-badge"; playerData.unlockedBadges = new java.util.HashSet<>(); Session session = new Session( - mock(GlobalConfig.class), + mock(org.xcore.plugin.config.TomlSecretsConfig.class), mock(Bundle.class), mock(MenuService.class), mock(PlayerDataRepository.class), @@ -225,8 +224,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); @@ -237,7 +236,7 @@ void passwordResetCommand_clearsPasswordWithoutRefresh() { playerData.uuid = "uuid-1"; playerData.password = "old-hash"; Session session = new Session( - mock(GlobalConfig.class), + mock(org.xcore.plugin.config.TomlSecretsConfig.class), mock(Bundle.class), mock(MenuService.class), mock(PlayerDataRepository.class), 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/LocalizationUserLanguageTest.java b/src/test/java/org/xcore/plugin/localization/LocalizationUserLanguageTest.java index 1420cee0..79c0454e 100644 --- a/src/test/java/org/xcore/plugin/localization/LocalizationUserLanguageTest.java +++ b/src/test/java/org/xcore/plugin/localization/LocalizationUserLanguageTest.java @@ -6,7 +6,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.repository.PlayerDataRepository; import org.xcore.plugin.model.PlayerData; import org.xcore.plugin.session.Session; @@ -71,7 +71,7 @@ private Session session(String clientLocale, String savedLanguage) { .build(); return new Session( - new GlobalConfig(), + new TomlSecretsConfig(), bundle, mock(MenuService.class), repository, 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..0b8b0ce6 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.TomlSecretsConfig; +import org.xcore.plugin.config.TomlXcoreConfig; import org.xcore.plugin.service.TranslationSafetyService; import java.io.IOException; @@ -168,21 +168,21 @@ 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); } - private GlobalConfig.TranslationProviderConfig providerConfig(String baseUrl, String apiMode) { + private TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig(String baseUrl, String apiMode) { return providerConfig(baseUrl, apiMode, "test-key", 0); } - private GlobalConfig.TranslationProviderConfig providerConfig(String baseUrl, String apiMode, String apiKey) { + private TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig(String baseUrl, String apiMode, String apiKey) { return providerConfig(baseUrl, apiMode, apiKey, 0); } - private GlobalConfig.TranslationProviderConfig providerConfig(String baseUrl, String apiMode, String apiKey, int maxRetries) { - GlobalConfig.TranslationProviderConfig providerConfig = new GlobalConfig.TranslationProviderConfig(); + private TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig(String baseUrl, String apiMode, String apiKey, int maxRetries) { + TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig = new TomlSecretsConfig.TranslationSection.ProviderConfig(); providerConfig.type = "openai"; providerConfig.apiKey = apiKey; providerConfig.baseUrl = baseUrl; 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..c94cb982 --- /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.TomlSecretsConfig; +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 TomlSecretsConfig()) + .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"); + + TomlSecretsConfig secretsConfig = new TomlSecretsConfig(); + secretsConfig.translation.providers = new LinkedHashMap<>(); + secretsConfig.translation.providers.put("openai-main", openAiProviderConfig(true)); + secretsConfig.translation.providers.put("google", googleProviderConfig(true)); + secretsConfig.translation.providers.put("openai-disabled", openAiProviderConfig(false)); + + TranslationProviderPipeline pipeline = new TranslationProviderFactory(config, secretsConfig) + .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 TomlSecretsConfig.TranslationSection.ProviderConfig openAiProviderConfig(boolean enabled) { + TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig = new TomlSecretsConfig.TranslationSection.ProviderConfig(); + providerConfig.type = "openai"; + providerConfig.enabled = enabled; + providerConfig.apiKey = "test-key"; + providerConfig.model = "gpt-test"; + providerConfig.normalize(); + return providerConfig; + } + + private TomlSecretsConfig.TranslationSection.ProviderConfig googleProviderConfig(boolean enabled) { + TomlSecretsConfig.TranslationSection.ProviderConfig providerConfig = new TomlSecretsConfig.TranslationSection.ProviderConfig(); + providerConfig.type = "google"; + providerConfig.enabled = enabled; + providerConfig.normalize(); + return providerConfig; + } +} diff --git a/src/test/java/org/xcore/plugin/security/ingress/checks/BanCheckTest.java b/src/test/java/org/xcore/plugin/security/ingress/checks/BanCheckTest.java index 40683ffd..2516d095 100644 --- a/src/test/java/org/xcore/plugin/security/ingress/checks/BanCheckTest.java +++ b/src/test/java/org/xcore/plugin/security/ingress/checks/BanCheckTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import com.ospx.flubundle.Bundle; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.repository.BanDataRepository; import org.xcore.plugin.model.BanData; import org.xcore.plugin.security.ingress.AccessResult; @@ -41,10 +41,10 @@ void setUp() { banDataRepository = mock(BanDataRepository.class); var bundle = IngressChecksTestSupport.testBundle(); - var globalConfig = new GlobalConfig(); - globalConfig.discordUrl = "https://discord.example"; + var secretsConfig = new TomlSecretsConfig(); + secretsConfig.externalLinks.discordUrl = "https://discord.example"; - check = new BanCheck(banDataRepository, bundle, globalConfig); + check = new BanCheck(banDataRepository, bundle, secretsConfig); } @AfterEach @@ -125,7 +125,7 @@ public String format(Locale locale, String id, Map args) { return id; } }; - check = new BanCheck(banDataRepository, bundle, new GlobalConfig()); + check = new BanCheck(banDataRepository, bundle, new TomlSecretsConfig()); var result = check.check(con, packet); diff --git a/src/test/java/org/xcore/plugin/security/ingress/checks/IpReputationCheckTest.java b/src/test/java/org/xcore/plugin/security/ingress/checks/IpReputationCheckTest.java index 7cc5fe95..73f303c1 100644 --- a/src/test/java/org/xcore/plugin/security/ingress/checks/IpReputationCheckTest.java +++ b/src/test/java/org/xcore/plugin/security/ingress/checks/IpReputationCheckTest.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.security.ingress.AccessResult; import org.xcore.plugin.security.ingress.ipreputation.IpReputationService; @@ -24,8 +24,8 @@ class IpReputationCheckTest { @BeforeEach void setUp() { ipReputationService = mock(IpReputationService.class); - var globalConfig = new GlobalConfig(); - globalConfig.discordUrl = "https://discord.example"; + var secretsConfig = new TomlSecretsConfig(); + secretsConfig.externalLinks.discordUrl = "https://discord.example"; Bundle bundle = new Bundle(Locale.ENGLISH) { @Override @@ -47,7 +47,7 @@ public String format(Locale locale, String id, Map args) { } }; - check = new IpReputationCheck(ipReputationService, bundle, globalConfig); + check = new IpReputationCheck(ipReputationService, bundle, secretsConfig); } @Test 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/IpApiProviderTest.java b/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpApiProviderTest.java index f812035a..ff89a585 100644 --- a/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpApiProviderTest.java +++ b/src/test/java/org/xcore/plugin/security/ingress/ipreputation/IpApiProviderTest.java @@ -2,7 +2,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import java.io.IOException; import java.net.http.HttpClient; @@ -137,14 +137,14 @@ void lookup_retriesThenReturnsNull() throws Exception { when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenThrow(new IOException("connection refused")); - GlobalConfig globalConfig = new GlobalConfig(); - globalConfig.ipReputationProvider.maxRetries = 2; - globalConfig.ipReputationProvider.timeoutSeconds = 1; + TomlSecretsConfig secretsConfig = new TomlSecretsConfig(); + secretsConfig.ipReputation.provider.maxRetries = 2; + secretsConfig.ipReputation.provider.timeoutSeconds = 1; - IpApiProvider provider = new IpApiProvider(globalConfig, client); + IpApiProvider provider = new IpApiProvider(secretsConfig, client); assertThat(provider.lookup("1.2.3.4")).isNull(); - verify(client, times(globalConfig.ipReputationProvider.maxRetries + 1)) + verify(client, times(secretsConfig.ipReputation.provider.maxRetries + 1)) .send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); } @@ -167,12 +167,12 @@ void lookup_rateLimitExceeded_returnsNullWithoutSecondRequest() throws Exception """); when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response); - GlobalConfig globalConfig = new GlobalConfig(); - globalConfig.ipReputationProvider.maxRetries = 0; - globalConfig.ipReputationProvider.timeoutSeconds = 1; - globalConfig.ipReputationProvider.rateLimitPerMinute = 1; + TomlSecretsConfig secretsConfig = new TomlSecretsConfig(); + secretsConfig.ipReputation.provider.maxRetries = 0; + secretsConfig.ipReputation.provider.timeoutSeconds = 1; + secretsConfig.ipReputation.provider.rateLimitPerMinute = 1; - IpApiProvider provider = new IpApiProvider(globalConfig, client); + IpApiProvider provider = new IpApiProvider(secretsConfig, client); assertThat(provider.lookup("1.2.3.4")).isNotNull(); assertThat(provider.lookup("5.6.7.8")).isNull(); @@ -200,11 +200,11 @@ void lookup_succeedsOnRetry() throws Exception { .thenThrow(new IOException("first attempt fails")) .thenReturn(response); - GlobalConfig globalConfig = new GlobalConfig(); - globalConfig.ipReputationProvider.maxRetries = 2; - globalConfig.ipReputationProvider.timeoutSeconds = 1; + TomlSecretsConfig secretsConfig = new TomlSecretsConfig(); + secretsConfig.ipReputation.provider.maxRetries = 2; + secretsConfig.ipReputation.provider.timeoutSeconds = 1; - IpApiProvider provider = new IpApiProvider(globalConfig, client); + IpApiProvider provider = new IpApiProvider(secretsConfig, client); IpReputationResult result = provider.lookup("1.2.3.4"); assertThat(result).isNotNull(); @@ -224,9 +224,9 @@ void lookup_interrupted_returnsNull() throws Exception { } private static IpApiProvider newProvider(HttpClient client) { - GlobalConfig globalConfig = new GlobalConfig(); - globalConfig.ipReputationProvider.maxRetries = 0; - globalConfig.ipReputationProvider.timeoutSeconds = 1; - return new IpApiProvider(globalConfig, client); + TomlSecretsConfig secretsConfig = new TomlSecretsConfig(); + secretsConfig.ipReputation.provider.maxRetries = 0; + secretsConfig.ipReputation.provider.timeoutSeconds = 1; + return new IpApiProvider(secretsConfig, client); } } 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/EventEditorServiceTest.java b/src/test/java/org/xcore/plugin/service/EventEditorServiceTest.java index d94014de..2c10092f 100644 --- a/src/test/java/org/xcore/plugin/service/EventEditorServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/EventEditorServiceTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.xcore.plugin.config.GlobalConfig; +import org.xcore.plugin.config.TomlSecretsConfig; import org.xcore.plugin.database.repository.EventDataRepository; import org.xcore.plugin.database.repository.MapDataRepository; import org.xcore.plugin.database.repository.PlayerDataRepository; @@ -219,7 +219,7 @@ private Session createSession() { MenuService menuService = new MenuService(sessionProvider, gateway); Session session = new Session( - new GlobalConfig(), + new TomlSecretsConfig(), mock(Bundle.class), menuService, mock(PlayerDataRepository.class), 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..86adf875 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.TomlSecretsConfig; +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,15 +67,15 @@ 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, mapDataRepository, mock(SessionService.class), config, - new GlobalConfig(), + new TomlSecretsConfig(), mock(VoteService.class), mock(VoteNewWaveFactory.class), mock(VoteRtvFactory.class), @@ -100,15 +100,15 @@ 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), mock(MapDataRepository.class), mock(SessionService.class), config, - new GlobalConfig(), + new TomlSecretsConfig(), mock(VoteService.class), mock(VoteNewWaveFactory.class), mock(VoteRtvFactory.class), @@ -150,8 +150,8 @@ void findPersistedMapPrefersExactPersistedIdentity() { mock(EventDataRepository.class), mock(MapDataRepository.class), mock(SessionService.class), - new Config(), - new GlobalConfig(), + new TomlXcoreConfig(), + new TomlSecretsConfig(), mock(VoteService.class), mock(VoteNewWaveFactory.class), mock(VoteRtvFactory.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..d13549d7 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.TomlSecretsConfig; +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,8 +66,8 @@ void startNewWaveSessionBlocksWhenWavesDisabled() { mock(EventDataRepository.class), mock(MapDataRepository.class), sessionService, - new Config(), - new GlobalConfig(), + new TomlXcoreConfig(), + new TomlSecretsConfig(), mock(VoteService.class), mock(VoteNewWaveFactory.class), mock(VoteRtvFactory.class), @@ -92,15 +92,15 @@ 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), mock(MapDataRepository.class), sessionService, config, - new GlobalConfig(), + new TomlSecretsConfig(), mock(VoteService.class), mock(VoteNewWaveFactory.class), mock(VoteRtvFactory.class), @@ -132,8 +132,8 @@ void forcedStartNewWaveSessionSkipsImmediately() { mock(EventDataRepository.class), mock(MapDataRepository.class), sessionService, - new Config(), - new GlobalConfig(), + new TomlXcoreConfig(), + new TomlSecretsConfig(), voteService, mock(VoteNewWaveFactory.class), mock(VoteRtvFactory.class), @@ -167,8 +167,8 @@ void startNewWaveSessionCreatesVoteWhenAllowed() { mock(EventDataRepository.class), mock(MapDataRepository.class), sessionService, - new Config(), - new GlobalConfig(), + new TomlXcoreConfig(), + new TomlSecretsConfig(), voteService, voteFactory, mock(VoteRtvFactory.class), 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..9e847beb 100644 --- a/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java +++ b/src/test/java/org/xcore/plugin/service/PrivateMessageServiceTest.java @@ -9,8 +9,8 @@ import org.junit.jupiter.api.DisplayName; 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.TomlSecretsConfig; +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,19 +274,25 @@ 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, TomlSecretsConfig.class, TomlXcoreConfig.class}; } @Override public void build(Builder builder) { - if (builder.isBeanAbsent(GlobalConfig.class)) { - builder.register(new GlobalConfig()); + if (builder.isBeanAbsent(TomlSecretsConfig.class)) { + builder.register(new TomlSecretsConfig()); } - 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,8 +300,8 @@ public void build(Builder builder) { builder.get(SessionService.class), builder.get(SecurityService.class), builder.get(NetworkService.class), - builder.get(Config.class), - builder.get(GlobalConfig.class) + builder.get(TomlXcoreConfig.class), + builder.get(TomlSecretsConfig.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