From 495a10311af696b635313aa0bece8cd1eacb7672 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 13:45:14 +0200 Subject: [PATCH 01/13] style: harden editorconfig style rules from suggestion to warning Promote dotnet/csharp style + naming rules from :suggestion to :warning so redundant style smells fail the build instead of accumulating silently. Co-Authored-By: Claude Opus 4.8 --- .editorconfig | 134 +++++++++++++++++++++++++------------------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/.editorconfig b/.editorconfig index 8bf951e..701b6b7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -20,9 +20,9 @@ trim_trailing_whitespace = true [*.cs] # Organize usings -dotnet_separate_import_directive_groups = false:suggestion -dotnet_sort_system_directives_first = false:suggestion -csharp_using_directive_placement = outside_namespace:suggestion +dotnet_separate_import_directive_groups = false:warning +dotnet_sort_system_directives_first = false:warning +csharp_using_directive_placement = outside_namespace:warning file_header_template = unset # this. and Me. preferences @@ -45,30 +45,30 @@ dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:sil dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent # Expression-level preferences -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_namespace_match_folder = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_object_initializer = true:suggestion +dotnet_style_coalesce_expression = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_namespace_match_folder = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_object_initializer = true:warning dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_collection_expression = when_types_loosely_match:warning +dotnet_style_prefer_compound_assignment = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:warning dotnet_style_prefer_conditional_expression_over_return = true:silent -dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:warning +dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:warning dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_simplified_interpolation = true:warning # Field preferences dotnet_style_readonly_field = true:warning # Parameter preferences -dotnet_code_quality_unused_parameters = all:suggestion +dotnet_code_quality_unused_parameters = all:warning # Suppression preferences dotnet_remove_unnecessary_suppression_exclusions = none @@ -76,9 +76,9 @@ dotnet_remove_unnecessary_suppression_exclusions = none #### C# Coding Conventions #### # var preferences -csharp_style_var_elsewhere = true:suggestion -csharp_style_var_for_built_in_types = true:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:warning +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning # Expression-bodied members csharp_style_expression_bodied_accessors = true:silent @@ -91,44 +91,44 @@ csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_properties = true:silent # Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_prefer_extended_property_pattern = true:suggestion -csharp_style_prefer_not_pattern = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_prefer_extended_property_pattern = true:warning +csharp_style_prefer_not_pattern = true:warning csharp_style_prefer_pattern_matching = true:silent -csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_switch_expression = true:warning # Null-checking preferences -csharp_style_conditional_delegate_call = true:suggestion +csharp_style_conditional_delegate_call = true:warning # Modifier preferences -csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_anonymous_function = true:warning csharp_prefer_static_local_function = true:warning -csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async:suggestion -csharp_style_prefer_readonly_struct = true:suggestion -csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async:warning +csharp_style_prefer_readonly_struct = true:warning +csharp_style_prefer_readonly_struct_member = true:warning # Code-block preferences -csharp_prefer_braces = true:suggestion -csharp_prefer_simple_using_statement = true:suggestion -csharp_style_namespace_declarations = file_scoped:suggestion +csharp_prefer_braces = true:warning +csharp_prefer_simple_using_statement = true:warning +csharp_style_namespace_declarations = file_scoped:warning csharp_style_prefer_method_group_conversion = true:silent -csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_primary_constructors = true:warning csharp_style_prefer_top_level_statements = true:silent # Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion -csharp_style_prefer_null_check_over_type_check = true:suggestion -csharp_style_prefer_range_operator = true:suggestion -csharp_style_prefer_tuple_swap = true:suggestion -csharp_style_prefer_utf8_string_literals = true:suggestion -csharp_style_throw_expression = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_prefer_simple_default_expression = true:warning +csharp_style_deconstructed_variable_declaration = true:warning +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning +csharp_style_inlined_variable_declaration = true:warning +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_local_over_anonymous_function = true:warning +csharp_style_prefer_null_check_over_type_check = true:warning +csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_tuple_swap = true:warning +csharp_style_prefer_utf8_string_literals = true:warning +csharp_style_throw_expression = true:warning +csharp_style_unused_value_assignment_preference = discard_variable:warning csharp_style_unused_value_expression_statement_preference = discard_variable:silent #### C# Formatting Rules #### @@ -182,79 +182,79 @@ csharp_preserve_single_line_statements = false # Naming rules -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = warning dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = warning dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase -dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = warning dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase -dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.severity = warning dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.severity = warning dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.severity = warning dotnet_naming_rule.events_should_be_pascalcase.symbols = events dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.severity = warning dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase -dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.severity = warning dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase -dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.severity = warning dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase -dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = warning dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.severity = warning dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = warning dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = _camelcase -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = warning dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = warning dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = warning dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = warning dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.severity = warning dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = warning dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase -dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = warning dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase From 2d8c6b4733120021e4a2fa09141836ec2dabafa2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 13:58:19 +0200 Subject: [PATCH 02/13] fix: resolve build errors surfaced by editorconfig hardening Promoting style rules to :warning under TreatWarningsAsErrors surfaced 5 latent violations. Two clean fixes (IDE0130 test namespace, IDE0028 collection expressions); IDE0060 folded into the existing MapIcons RCS1163 suppression. IDE0045 (AlarmEmbedRenderer) and IDE0290 (PairingSupervisor.Handle) are suppressed at the line with documented justification: their "fixes" conflict with RCS1238/S3358 (nested ternary) and CA2000 (CTS on a primary ctor). Co-Authored-By: Claude Opus 4.8 --- .../Rendering/AlarmEmbedRenderer.cs | 2 ++ src/RustPlusBot.Features.Map/Assets/MapIcons.cs | 4 ++-- .../Supervisor/PairingSupervisor.cs | 2 ++ .../Locating/CachingChannelLocator.cs | 4 ++-- .../Specs/ServerWorkspaceSpecProviderTests.cs | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs index 539202a..d68c26c 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs @@ -20,6 +20,7 @@ internal sealed class AlarmEmbedRenderer(IAlarmLocalizer localizer, IClock clock ArgumentNullException.ThrowIfNull(alarm); string statusKey; +#pragma warning disable IDE0045 // Collapsing to a nested ternary trips RCS1238/S3358 (nested conditional); the if/else chain is intentional. if (unreachable) { statusKey = "alarm.status.unreachable"; @@ -32,6 +33,7 @@ internal sealed class AlarmEmbedRenderer(IAlarmLocalizer localizer, IClock clock { statusKey = "alarm.status.armed"; } +#pragma warning restore IDE0045 var triggered = alarm.LastTriggeredUtc is { } t ? localizer.Get("alarm.embed.lasttriggered", culture, CompactDuration(clock.UtcNow - t)) diff --git a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs index 0f51e0e..fc58d91 100644 --- a/src/RustPlusBot.Features.Map/Assets/MapIcons.cs +++ b/src/RustPlusBot.Features.Map/Assets/MapIcons.cs @@ -40,14 +40,14 @@ public static class MapIcons /// The parameter is reserved for future use: the renderer is responsible /// for overlaying activation styling; this method always returns the base icon regardless of state. /// -#pragma warning disable RCS1163 // Unused parameter — 'active' is part of the public API contract; activation styling is applied by the renderer, not here. +#pragma warning disable RCS1163, IDE0060 // Unused parameter — 'active' is part of the public API contract; activation styling is applied by the renderer, not here. public static Image? Rig(RigKind kind, bool active) => kind switch { RigKind.Small => Load("oilrig"), RigKind.Large => Load("largeoilrig"), _ => null, }; -#pragma warning restore RCS1163 +#pragma warning restore RCS1163, IDE0060 /// Gets the travelling vendor icon. /// The cached vendor icon image, or null. diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs index d13cab4..5b39327 100644 --- a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs +++ b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs @@ -260,11 +260,13 @@ private sealed class Handle : IAsyncDisposable private readonly CancellationTokenSource _cts; private readonly Task _runTask; +#pragma warning disable IDE0290 // A primary constructor trips CA2000 on the CancellationTokenSource field; the explicit ctor is intentional. public Handle(CancellationTokenSource cts, Task runTask) { _cts = cts; _runTask = runTask; } +#pragma warning restore IDE0290 public ValueTask DisposeAsync() { diff --git a/src/RustPlusBot.Features.Workspace/Locating/CachingChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/CachingChannelLocator.cs index 3ef41a0..4a28652 100644 --- a/src/RustPlusBot.Features.Workspace/Locating/CachingChannelLocator.cs +++ b/src/RustPlusBot.Features.Workspace/Locating/CachingChannelLocator.cs @@ -14,7 +14,7 @@ internal abstract class CachingChannelLocator(IServiceScopeFactory scopeFactory, private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); private readonly SemaphoreSlim _refreshGate = new(1, 1); private DateTimeOffset _builtAt = DateTimeOffset.MinValue; - private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = new(); + private Dictionary<(ulong GuildId, Guid ServerId), ulong> _byServer = []; /// A read-only view of the cached (guild, server) → channel-id map, available after ensure-fresh. protected IReadOnlyDictionary<(ulong GuildId, Guid ServerId), ulong> Entries => _byServer; @@ -73,7 +73,7 @@ protected async Task EnsureFreshAsync(CancellationToken cancellationToken) var store = scope.ServiceProvider.GetRequiredService(); var rows = await store.GetChannelsByKeyAsync(channelKey, cancellationToken).ConfigureAwait(false); - var byServer = new Dictionary<(ulong GuildId, Guid ServerId), ulong>(); + Dictionary<(ulong GuildId, Guid ServerId), ulong> byServer = []; foreach (var row in rows) { if (row.RustServerId is not { } serverId) diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs index 173a22b..9e002ff 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Specs/ServerWorkspaceSpecProviderTests.cs @@ -2,7 +2,7 @@ using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Features.Workspace.Specs; -namespace RustPlusBot.Features.Workspace.Tests; +namespace RustPlusBot.Features.Workspace.Tests.Specs; public sealed class ServerWorkspaceSpecProviderTests { From 792cde01f154d4f47d4b51b908d85d4d4ff38fad Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:04:04 +0200 Subject: [PATCH 03/13] feat(localization): add shared Strings.resx (en/fr) generated from catalogs 193 keys, globally unique across all 6 feature catalogs, generated by reflection from the compiled *LocalizationCatalog.Default values so emoji/accents/{0} are byte-exact. NeutralResourcesLanguage("en") makes the neutral set the English fallback; verified ResourceManager round-trip resolves en/fr, falls back de->en, and walks fr-FR->fr. Co-Authored-By: Claude Opus 4.8 --- src/RustPlusBot.Localization/AssemblyInfo.cs | 5 + .../RustPlusBot.Localization.csproj | 11 +- src/RustPlusBot.Localization/Strings.fr.resx | 594 ++++++++++++++++++ src/RustPlusBot.Localization/Strings.resx | 594 ++++++++++++++++++ 4 files changed, 1203 insertions(+), 1 deletion(-) create mode 100644 src/RustPlusBot.Localization/AssemblyInfo.cs create mode 100644 src/RustPlusBot.Localization/Strings.fr.resx create mode 100644 src/RustPlusBot.Localization/Strings.resx diff --git a/src/RustPlusBot.Localization/AssemblyInfo.cs b/src/RustPlusBot.Localization/AssemblyInfo.cs new file mode 100644 index 0000000..b719b17 --- /dev/null +++ b/src/RustPlusBot.Localization/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Resources; + +// The neutral Strings.resx holds the English strings, so the runtime treats the +// neutral culture as English and a missing fr key falls back to it automatically. +[assembly: NeutralResourcesLanguage("en")] diff --git a/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj b/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj index c632161..3acf077 100644 --- a/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj +++ b/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj @@ -1,3 +1,12 @@ - + + + + + + + + Strings.resx + + diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx new file mode 100644 index 0000000..6ccfa30 --- /dev/null +++ b/src/RustPlusBot.Localization/Strings.fr.resx @@ -0,0 +1,594 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ping @everyone : désactivé + + + Ping @everyone : activé + + + Relais tchat équipe : désactivé + + + Relais tchat équipe : activé + + + Renommer + + + Entité {0} + + + Déclenchée il y a {0} + + + Jamais déclenchée + + + Accepter + + + Nouvelle alarme connectée détectée ({0}). L'ajouter ? + + + Ignorer + + + Nouvelle alarme détectée + + + Nom de l'alarme + + + Renommer l'alarme + + + 🚨 Active + + + 🔔 Armée + + + ⚠️ Injoignable + + + 🚨 {0} déclenchée + + + RustPlusBot + + + alarmes + + + evenements + + + info + + + informations + + + carte + + + parametres + + + configuration + + + interrupteurs + + + tchat-equipe + + + {0} ({1}) + + + Personne n'est AFK. + + + AFK : {0} + + + {0} mort + + + {0} {1} + + + En vie : {0} + + + Aucun cargo sur la carte. + + + Cargo en {0} (il y a {1}) + + + Aucun chinook sur la carte. + + + Chinook en {0} (il y a {1}) + + + cargo en {0} + + + cargo parti de {0} + + + chinook en {0} + + + heli en {0} + + + heli parti de {0} + + + Aucun événement récent. + + + Récent : {0} + + + Aucun hélicoptère sur la carte. + + + Hélicoptère en {0} (il y a {1}) + + + Grande plateforme : phase de combat — caisse lootable dans {0}. + + + Grande plateforme : pillée / dormante — réapparaît dans {0}. + + + Grande plateforme : caisse prête, en attente d'activation. + + + Bot mis en sourdine. + + + Non connecté au serveur. + + + Tout le monde est en ligne. + + + Hors ligne ({0}) : {1} + + + Personne n'est en ligne. + + + En ligne ({0}) : {1} + + + Population : {0}/{1} ({2} en file) + + + Aucun coéquipier à proximité. + + + {0} {1}m + + + Prox : {0} + + + Impossible de vous localiser. + + + Aucun serveur n'est encore configuré. + + + Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur. + + + Ce serveur n'est pas configuré. + + + Petite plateforme : phase de combat — caisse lootable dans {0}. + + + Petite plateforme : pillée / dormante — réapparaît dans {0}. + + + Petite plateforme : caisse prête, en attente d'activation. + + + {0} + + + Aucun coéquipier ne correspond à « {0} ». + + + Aucun membre d'équipe. + + + Équipe ({0}) : {1} + + + jour + + + nuit + + + Heure : {0} — {1} + + + Bot réactivé. + + + Disponibilité : {0} + + + Wipe il y a {0} + + + Heure de wipe inconnue. + + + 🚢 Cargo Ship arrivé en {0} + + + Cargo Ship arrivé en {0} + + + 🚢 Cargo Ship parti ({0}) + + + Cargo Ship parti ({0}) + + + 🚁 Chinook apparu en {0} + + + Chinook apparu en {0} + + + 🚁 Hélicoptère de patrouille arrivé en {0} + + + Hélicoptère de patrouille arrivé en {0} + + + 🚁 Hélicoptère de patrouille parti ({0}) + + + Hélicoptère de patrouille parti ({0}) + + + 🛢️ Grande plateforme pétrolière activée — phase de combat, caisse bientôt lootable ({0}) + + + Grande plateforme activée — phase de combat ({0}) + + + 🛢️ Grande plateforme pétrolière — caisse LOOTABLE ({0}) + + + Grande plateforme — caisse lootable ({0}) + + + 🛢️ Grande plateforme pétrolière — caisse réapparue, réarmée ({0}) + + + Grande plateforme — caisse réapparue ({0}) + + + 🛢️ Petite plateforme pétrolière activée — phase de combat, caisse bientôt lootable ({0}) + + + Petite plateforme activée — phase de combat ({0}) + + + 🛢️ Petite plateforme pétrolière — caisse LOOTABLE ({0}) + + + Petite plateforme — caisse lootable ({0}) + + + 🛢️ Petite plateforme pétrolière — caisse réapparue, réarmée ({0}) + + + Petite plateforme — caisse réapparue ({0}) + + + Événement + + + Lister les coéquipiers qui sont AFK. + + + Afficher depuis combien de temps chaque coéquipier survit. + + + Bot + + + Contrôle + + + Serveur + + + Commandes slash + + + Équipe + + + Couper toute sortie du bot vers le jeu. + + + Lister les coéquipiers hors ligne. + + + Lister les coéquipiers en ligne. + + + Afficher la population du serveur. + + + Les préfixes sont configurés par serveur ; affichage du préfixe par défaut. + + + Afficher la distance à chaque coéquipier. + + + Afficher ce message d'aide. + + + Transférer le rôle de chef d'équipe en jeu. + + + Afficher la disponibilité du bot. + + + Afficher les identifiants Steam des coéquipiers. + + + Lister tous les membres de l'équipe. + + + Afficher l'heure en jeu. + + + Commandes + + + Réactiver la sortie du bot vers le jeu. + + + Afficher depuis combien de temps le bot fonctionne. + + + Afficher depuis combien de temps le serveur a été wipe. + + + Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu. + + + Serveurs enregistres : {0} + + + RustPlusBot + + + Impossible de promouvoir — le serveur est peut-être injoignable. + + + Aucun serveur n'est encore configuré. + + + Non connecté à ce serveur. + + + Aucun membre d'équipe à promouvoir. + + + Choisissez qui promouvoir chef d'équipe : + + + Choisissez un serveur : + + + {0} promu chef d'équipe. + + + Calques de la carte — activer/désactiver : + + + Grille + + + Marqueurs + + + Monuments + + + Joueurs + + + Plateformes + + + Marchand + + + 💤 {0} est AFK ({1}) + + + 👋 {0} est de retour + + + {0} est de retour + + + {0} est AFK ({1}) + + + 🟢 {0} s'est connecté + + + {0} s'est connecté + + + 💀 {0} est mort en {1} + + + {0} est mort en {1} + + + 💀 {0} est mort + + + {0} est mort + + + 🔴 {0} s'est déconnecté + + + {0} s'est déconnecté + + + ✨ {0} a réapparu en {1} + + + {0} a réapparu en {1} + + + Événement d'équipe + + + Adresse : {0}:{1} + + + + + + Joueur actif + + + Joueurs en ligne + + + Supprimer le serveur + + + Connecté + + + Connexion… + + + État + + + Aucun identifiant valide + + + Serveur injoignable + + + Changer le joueur actif + + + Équipe + + + {0}/{1} en ligne · chef {2} + + + Configurez le bot pour ce serveur. + + + Langue + + + Parametres + + + Cliquez sur **Connecter le compte** ci-dessous et collez vos identifiants FCM Rust+. Appairez ensuite un serveur en jeu et ses salons apparaitront automatiquement. + + + Connecter le compte + + + Déconnecter le compte + + + Connectez votre compte Rust+ + + + Éteindre + + + Allumer + + + Renommer + + + Stroboscope + + + Entité {0} + + + Accepter + + + Nouvel interrupteur connecté détecté ({0}). L'ajouter ? + + + Ignorer + + + Ignoré. + + + Nouvel interrupteur détecté + + + Nom de l'interrupteur + + + Renommer l'interrupteur + + + ⭘ ÉTEINT + + + ⚡ ALLUMÉ + + + ⚠️ Injoignable + + + L'interrupteur est injoignable pour le moment. + + + Disponibilité du bot : {0} + + \ No newline at end of file diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx new file mode 100644 index 0000000..8875517 --- /dev/null +++ b/src/RustPlusBot.Localization/Strings.resx @@ -0,0 +1,594 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ping @everyone: off + + + Ping @everyone: on + + + Relay to team chat: off + + + Relay to team chat: on + + + Rename + + + Entity {0} + + + Last triggered {0} ago + + + Never triggered + + + Accept + + + Detected a new Smart Alarm ({0}). Add it? + + + Dismiss + + + New alarm detected + + + Alarm name + + + Rename alarm + + + 🚨 Active + + + 🔔 Armed + + + ⚠️ Unreachable + + + 🚨 {0} triggered + + + RustPlusBot + + + alarms + + + events + + + info + + + information + + + map + + + settings + + + setup + + + switches + + + teamchat + + + {0} ({1}) + + + Nobody is AFK. + + + AFK: {0} + + + {0} dead + + + {0} {1} + + + Alive: {0} + + + No cargo ship on the map. + + + Cargo Ship at {0} ({1} ago) + + + No chinook on the map. + + + Chinook at {0} ({1} ago) + + + cargo in {0} + + + cargo left {0} + + + chinook in {0} + + + heli in {0} + + + heli left {0} + + + No recent events. + + + Recent: {0} + + + No patrol helicopter on the map. + + + Patrol Helicopter at {0} ({1} ago) + + + Large Oil Rig: combat phase — crate lootable in {0}. + + + Large Oil Rig: looted / dormant — respawns in {0}. + + + Large Oil Rig: crate ready, waiting for activation. + + + Bot muted. + + + Not connected to the server. + + + Everyone is online. + + + Offline ({0}): {1} + + + No one is online. + + + Online ({0}): {1} + + + Pop: {0}/{1} ({2} queued) + + + No teammates nearby. + + + {0} {1}m + + + Prox: {0} + + + Can't locate you. + + + No server is set up yet. + + + Multiple servers are set up — choose one with the server option. + + + That server isn't set up. + + + Small Oil Rig: combat phase — crate lootable in {0}. + + + Small Oil Rig: looted / dormant — respawns in {0}. + + + Small Oil Rig: crate ready, waiting for activation. + + + {0} + + + No teammate matches '{0}'. + + + No team members. + + + Team ({0}): {1} + + + day + + + night + + + Time: {0} — {1} + + + Bot unmuted. + + + Uptime: {0} + + + Wiped {0} ago + + + Wipe time is unknown. + + + 🚢 Cargo Ship entered at {0} + + + Cargo Ship entered at {0} + + + 🚢 Cargo Ship left ({0}) + + + Cargo Ship left ({0}) + + + 🚁 Chinook spawned at {0} + + + Chinook spawned at {0} + + + 🚁 Patrol Helicopter entered at {0} + + + Patrol Helicopter entered at {0} + + + 🚁 Patrol Helicopter left ({0}) + + + Patrol Helicopter left ({0}) + + + 🛢️ Large Oil Rig activated — combat phase, crate lootable soon ({0}) + + + Large Oil Rig activated — combat phase ({0}) + + + 🛢️ Large Oil Rig — crate is now LOOTABLE ({0}) + + + Large Oil Rig — crate is now lootable ({0}) + + + 🛢️ Large Oil Rig — crate respawned, armed again ({0}) + + + Large Oil Rig — crate respawned ({0}) + + + 🛢️ Small Oil Rig activated — combat phase, crate lootable soon ({0}) + + + Small Oil Rig activated — combat phase ({0}) + + + 🛢️ Small Oil Rig — crate is now LOOTABLE ({0}) + + + Small Oil Rig — crate is now lootable ({0}) + + + 🛢️ Small Oil Rig — crate respawned, armed again ({0}) + + + Small Oil Rig — crate respawned ({0}) + + + Live event + + + List teammates who are AFK. + + + Show how long each teammate has survived. + + + Bot + + + Control + + + Server + + + Slash commands + + + Team + + + Silence all bot output to the game. + + + List offline teammates. + + + List online teammates. + + + Show the server population. + + + Prefixes are configured per server; showing the default. + + + Show distance to each teammate. + + + Show this help message. + + + Transfer in-game team leadership. + + + Show the bot's uptime. + + + Show teammates' Steam IDs. + + + List all team members. + + + Show the in-game time. + + + Commands + + + Resume bot output to the game. + + + Show how long the bot has been running. + + + Show how long ago the server wiped. + + + Connect your Rust+ account in #setup, then pair a server in-game to begin. + + + Servers registered: {0} + + + RustPlusBot + + + Couldn't promote — the server may be unreachable. + + + No server is set up yet. + + + Not connected to this server. + + + No team members to promote. + + + Choose who to promote to team leader: + + + Choose a server: + + + Promoted {0} to team leader. + + + Map layers — toggle to show/hide: + + + Grid + + + Markers + + + Monuments + + + Players + + + Rigs + + + Vendor + + + 💤 {0} is AFK ({1}) + + + 👋 {0} is back + + + {0} is back + + + {0} is AFK ({1}) + + + 🟢 {0} connected + + + {0} connected + + + 💀 {0} died at {1} + + + {0} died at {1} + + + 💀 {0} died + + + {0} died + + + 🔴 {0} disconnected + + + {0} disconnected + + + ✨ {0} respawned at {1} + + + {0} respawned at {1} + + + Team event + + + Endpoint: {0}:{1} + + + + + + Active player + + + Players online + + + Remove server + + + Connected + + + Connecting… + + + Status + + + No working credentials + + + Server unreachable + + + Switch active player + + + Team + + + {0}/{1} online · leader {2} + + + Configure the bot for this server. + + + Language + + + Settings + + + Click **Connect account** below and paste your Rust+ FCM credentials. Then pair a server in-game and its channels appear automatically. + + + Connect account + + + Disconnect account + + + Connect your Rust+ account + + + Turn off + + + Turn on + + + Rename + + + Strobe + + + Entity {0} + + + Accept + + + Detected a new Smart Switch ({0}). Add it? + + + Dismiss + + + Dismissed. + + + New switch detected + + + Switch name + + + Rename switch + + + ⭘ OFF + + + ⚡ ON + + + ⚠️ Unreachable + + + Switch is unreachable right now. + + + Bot uptime: {0} + + \ No newline at end of file From 77db13a46f0035cf6ac23d99fdb3fa8377ca28d2 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:08:42 +0200 Subject: [PATCH 04/13] feat(localization): add ResxLocalizer + AddRustPlusBotLocalization DI extension ResxLocalizer resolves per-call culture via ResourceManager.GetString over the shared Strings resource set, preserving the ILocalizer contract: en fallback, fr-FR->fr normalization, key returned when missing, per-culture format provider. AddRustPlusBotLocalization registers it idempotently (TryAddSingleton). Co-Authored-By: Claude Opus 4.8 --- Directory.Packages.props | 1 + ...LocalizationServiceCollectionExtensions.cs | 18 ++++++ src/RustPlusBot.Localization/ResxLocalizer.cs | 59 +++++++++++++++++ .../RustPlusBot.Localization.csproj | 4 ++ .../ResxLocalizerTests.cs | 63 +++++++++++++++++++ 5 files changed, 145 insertions(+) create mode 100644 src/RustPlusBot.Localization/LocalizationServiceCollectionExtensions.cs create mode 100644 src/RustPlusBot.Localization/ResxLocalizer.cs create mode 100644 tests/RustPlusBot.Localization.Tests/ResxLocalizerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 7643b91..af61d2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/src/RustPlusBot.Localization/LocalizationServiceCollectionExtensions.cs b/src/RustPlusBot.Localization/LocalizationServiceCollectionExtensions.cs new file mode 100644 index 0000000..7a120ef --- /dev/null +++ b/src/RustPlusBot.Localization/LocalizationServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace RustPlusBot.Localization; + +/// DI registration for the shared localizer. +public static class LocalizationServiceCollectionExtensions +{ + /// Registers the shared (idempotent). + /// The service collection. + /// The same service collection for chaining. + public static IServiceCollection AddRustPlusBotLocalization(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(); + return services; + } +} diff --git a/src/RustPlusBot.Localization/ResxLocalizer.cs b/src/RustPlusBot.Localization/ResxLocalizer.cs new file mode 100644 index 0000000..bfa8699 --- /dev/null +++ b/src/RustPlusBot.Localization/ResxLocalizer.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using System.Resources; + +namespace RustPlusBot.Localization; + +/// +/// backed by the embedded Strings resource set, +/// resolving per-call culture with English fallback and region normalization. +/// +public sealed class ResxLocalizer : ILocalizer +{ + private const string FallbackCulture = "en"; + + private static readonly ResourceManager Resources = + new("RustPlusBot.Localization.Strings", typeof(ResxLocalizer).Assembly); + + /// + public string Get(string key, string culture) + { + var info = ResolveCulture(culture); + + // ResourceManager walks the requested culture down to the neutral (en) set, + // so a single lookup already covers the English fallback. + var value = Resources.GetString(key, info); + return value ?? key; + } + + /// + public string Get(string key, string culture, params object[] args) + { + var format = Get(key, culture); + return string.Format(ResolveCulture(culture), format, args); + } + + private static CultureInfo ResolveCulture(string culture) + { + var normalized = Normalize(culture); + try + { + return CultureInfo.GetCultureInfo(normalized); + } + catch (CultureNotFoundException) + { + return CultureInfo.InvariantCulture; + } + } + + private static string Normalize(string culture) + { + if (string.IsNullOrWhiteSpace(culture)) + { + return FallbackCulture; + } + + var dash = culture.IndexOf('-', StringComparison.Ordinal); + var primary = dash >= 0 ? culture[..dash] : culture; + return primary.ToLowerInvariant(); + } +} diff --git a/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj b/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj index 3acf077..6f08608 100644 --- a/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj +++ b/src/RustPlusBot.Localization/RustPlusBot.Localization.csproj @@ -1,5 +1,9 @@ + + + + diff --git a/tests/RustPlusBot.Localization.Tests/ResxLocalizerTests.cs b/tests/RustPlusBot.Localization.Tests/ResxLocalizerTests.cs new file mode 100644 index 0000000..d1de8f5 --- /dev/null +++ b/tests/RustPlusBot.Localization.Tests/ResxLocalizerTests.cs @@ -0,0 +1,63 @@ +using RustPlusBot.Localization; + +namespace RustPlusBot.Localization.Tests; + +public sealed class ResxLocalizerTests +{ + private static readonly ResxLocalizer Sut = new(); + + [Fact] + public void Get_returns_english_value() + { + Assert.Equal("⚡ ON", Sut.Get("switch.status.on", "en")); + } + + [Fact] + public void Get_returns_french_value() + { + Assert.Equal("⚡ ALLUMÉ", Sut.Get("switch.status.on", "fr")); + } + + [Fact] + public void Get_falls_back_to_english_for_unknown_culture() + { + Assert.Equal("⚡ ON", Sut.Get("switch.status.on", "de")); + } + + [Fact] + public void Get_normalizes_region_specific_culture() + { + Assert.Equal("⚡ ALLUMÉ", Sut.Get("switch.status.on", "fr-FR")); + } + + [Fact] + public void Get_falls_back_to_english_for_blank_culture() + { + Assert.Equal("⚡ ON", Sut.Get("switch.status.on", "")); + } + + [Fact] + public void Get_returns_key_when_missing() + { + Assert.Equal("nonexistent.key", Sut.Get("nonexistent.key", "en")); + } + + [Fact] + public void Get_with_args_formats_english() + { + Assert.Equal("Endpoint: 1.2.3.4:28015", Sut.Get("server.info.endpoint", "en", "1.2.3.4", 28015)); + } + + [Fact] + public void Get_with_args_formats_french() + { + Assert.Equal("Adresse : 1.2.3.4:28015", Sut.Get("server.info.endpoint", "fr", "1.2.3.4", 28015)); + } + + [Fact] + public void Get_with_args_on_missing_key_formats_the_key() + { + // Missing key returns the key itself; with no placeholders, args are ignored. + Assert.Equal("nonexistent.key", Sut.Get("nonexistent.key", "en", "x")); + } +} From 536e7710159fdc3bcda011bf918cd0cda8685910 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:08:42 +0200 Subject: [PATCH 05/13] test(localization): add en/fr resource parity + key-count guard Enumerates the neutral (en, invariant-keyed) and fr resource sets directly so a dropped or renamed key fails the build. Asserts 193 keys with matching sets. Co-Authored-By: Claude Opus 4.8 --- .../StringsResourceParityTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs diff --git a/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs new file mode 100644 index 0000000..0a3b6f6 --- /dev/null +++ b/tests/RustPlusBot.Localization.Tests/StringsResourceParityTests.cs @@ -0,0 +1,47 @@ +using System.Collections; +using System.Globalization; +using System.Resources; +using RustPlusBot.Localization; + +namespace RustPlusBot.Localization.Tests; + +public sealed class StringsResourceParityTests +{ + private static readonly ResourceManager Resources = + new("RustPlusBot.Localization.Strings", typeof(ResxLocalizer).Assembly); + + private static HashSet Keys(CultureInfo culture) + { + // Do not dispose: ResourceManager caches and reuses the returned set instance, + // so disposing it here would break the next culture's lookup. + var set = Resources.GetResourceSet(culture, createIfNotExists: true, tryParents: false)!; + return set.Cast() + .Select(e => (string)e.Key) + .ToHashSet(StringComparer.Ordinal); + } + + private static HashSet EnglishKeys() => + // English lives in the neutral set (NeutralResourcesLanguage("en")), keyed by + // the invariant culture — there is no "en" satellite to read. + Keys(CultureInfo.InvariantCulture); + + private static HashSet FrenchKeys() => Keys(CultureInfo.GetCultureInfo("fr")); + + [Fact] + public void French_covers_every_english_key() + { + Assert.Empty(EnglishKeys().Except(FrenchKeys())); + } + + [Fact] + public void English_covers_every_french_key() + { + Assert.Empty(FrenchKeys().Except(EnglishKeys())); + } + + [Fact] + public void Catalog_has_expected_key_count() + { + Assert.Equal(193, EnglishKeys().Count); + } +} From da2df96d9da356a0a7ef10ee086a99c9bd5fe0ab Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:19:01 +0200 Subject: [PATCH 06/13] refactor(commands): use shared ILocalizer/ResxLocalizer, drop command catalog Co-Authored-By: Claude Sonnet 4.6 --- .../CommandServiceCollectionExtensions.cs | 6 +- .../Handlers/AfkCommandHandler.cs | 4 +- .../Handlers/AliveCommandHandler.cs | 4 +- .../Handlers/CargoCommandHandler.cs | 4 +- .../Handlers/ChinookCommandHandler.cs | 4 +- .../Handlers/EventsCommandHandler.cs | 4 +- .../Handlers/HeliCommandHandler.cs | 4 +- .../Handlers/LargeCommandHandler.cs | 4 +- .../Handlers/MuteCommandHandler.cs | 4 +- .../Handlers/OfflineCommandHandler.cs | 4 +- .../Handlers/OnlineCommandHandler.cs | 4 +- .../Handlers/PopCommandHandler.cs | 4 +- .../Handlers/ProxCommandHandler.cs | 4 +- .../Handlers/RigReply.cs | 4 +- .../Handlers/SmallCommandHandler.cs | 4 +- .../Handlers/SteamIdCommandHandler.cs | 4 +- .../Handlers/TeamCommandHandler.cs | 4 +- .../Handlers/TimeCommandHandler.cs | 4 +- .../Handlers/UnmuteCommandHandler.cs | 4 +- .../Handlers/UptimeCommandHandler.cs | 4 +- .../Handlers/WipeCommandHandler.cs | 4 +- .../Help/HelpEmbedRenderer.cs | 4 +- .../Leader/LeaderService.cs | 4 +- .../CommandLocalizationCatalog.cs | 185 ------------------ .../Localization/CommandLocalizer.cs | 8 - .../Localization/ICommandLocalizer.cs | 6 - .../Modules/CommandSurfaceModule.cs | 8 +- .../Modules/LeaderComponentModule.cs | 4 +- .../Servers/ServerResolver.cs | 4 +- .../AfkCommandHandlerTests.cs | 4 +- .../CommandRegistrationTests.cs | 4 +- .../Handlers/EventHandlersTests.cs | 6 +- .../Handlers/MuteHandlersTests.cs | 4 +- .../Handlers/QueryHandlersTests.cs | 4 +- .../Handlers/RigCommandHandlersTests.cs | 5 +- .../Handlers/TeamIntelHandlersTests.cs | 4 +- .../Help/CommandHelpCatalogTests.cs | 4 +- .../Help/HelpEmbedRendererTests.cs | 5 +- .../Leader/LeaderServiceTests.cs | 4 +- .../Localization/CommandLocalizerTests.cs | 24 --- .../Servers/ServerResolverTests.cs | 4 +- 41 files changed, 77 insertions(+), 304 deletions(-) delete mode 100644 src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs delete mode 100644 src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs delete mode 100644 src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs delete mode 100644 tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index 207eb83..fd24d92 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -5,8 +5,8 @@ using RustPlusBot.Features.Commands.Help; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Commands.Servers; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands; @@ -22,9 +22,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - // CommandLocalizationCatalog has a required member, so register the prebuilt Default instance. - services.AddSingleton(CommandLocalizationCatalog.Default); - services.AddSingleton(); + services.AddRustPlusBotLocalization(); services.AddScoped(); services.AddScoped(); diff --git a/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs index 2fcd2cc..8d0e179 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/AfkCommandHandler.cs @@ -1,14 +1,14 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !afk — lists team members who have been still (online + alive) past the AFK threshold. /// The live AFK state. /// The reply localizer. -internal sealed class AfkCommandHandler(IAfkState afk, ICommandLocalizer localizer) : ICommandHandler +internal sealed class AfkCommandHandler(IAfkState afk, ILocalizer localizer) : ICommandHandler { /// public string Name => "afk"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs index bc3324a..c953702 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/AliveCommandHandler.cs @@ -2,7 +2,7 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,7 +10,7 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live server query. /// The reply localizer. /// The clock used to compute survival durations. -internal sealed class AliveCommandHandler(IRustServerQuery query, ICommandLocalizer localizer, IClock clock) +internal sealed class AliveCommandHandler(IRustServerQuery query, ILocalizer localizer, IClock clock) : ICommandHandler { /// diff --git a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs index a0770f6..c6a5867 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs @@ -2,9 +2,9 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; @@ -12,7 +12,7 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class CargoCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock) +internal sealed class CargoCommandHandler(IEventState state, ILocalizer localizer, IClock clock) : ICommandHandler { /// diff --git a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs index b08af67..6368b22 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs @@ -2,9 +2,9 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; @@ -12,7 +12,7 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class ChinookCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock) +internal sealed class ChinookCommandHandler(IEventState state, ILocalizer localizer, IClock clock) : ICommandHandler { /// diff --git a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs index 2777cc9..63657a4 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/EventsCommandHandler.cs @@ -1,15 +1,15 @@ using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !events — lists the most recent live events. /// The live event state. /// The reply localizer. -internal sealed class EventsCommandHandler(IEventState state, ICommandLocalizer localizer) : ICommandHandler +internal sealed class EventsCommandHandler(IEventState state, ILocalizer localizer) : ICommandHandler { /// public string Name => "events"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs index 1f5e61d..8ce6116 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs @@ -2,9 +2,9 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; @@ -12,7 +12,7 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live event state. /// The reply localizer. /// For "how long ago". -internal sealed class HeliCommandHandler(IEventState state, ICommandLocalizer localizer, IClock clock) +internal sealed class HeliCommandHandler(IEventState state, ILocalizer localizer, IClock clock) : ICommandHandler { /// diff --git a/src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs index 14b67da..433792b 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/LargeCommandHandler.cs @@ -1,14 +1,14 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !large — reports the large oil rig's status. /// The rig state reader. /// The reply localizer. -internal sealed class LargeCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler +internal sealed class LargeCommandHandler(IRigState rigState, ILocalizer localizer) : ICommandHandler { /// public string Name => "large"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs index cc1d5e3..7e4d671 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/MuteCommandHandler.cs @@ -1,5 +1,5 @@ using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Commands; namespace RustPlusBot.Features.Commands.Handlers; @@ -7,7 +7,7 @@ namespace RustPlusBot.Features.Commands.Handlers; /// !mute — silence all bot-to-game output. /// The mute store. /// The reply localizer. -internal sealed class MuteCommandHandler(IMuteStore store, ICommandLocalizer localizer) : ICommandHandler +internal sealed class MuteCommandHandler(IMuteStore store, ILocalizer localizer) : ICommandHandler { /// public string Name => "mute"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs index 7d360b6..3abd423 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/OfflineCommandHandler.cs @@ -1,13 +1,13 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !offline — lists teammates not currently connected. /// The live server query. /// The reply localizer. -internal sealed class OfflineCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler +internal sealed class OfflineCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler { /// public string Name => "offline"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs index 9dc61ad..9ddc5b6 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/OnlineCommandHandler.cs @@ -1,13 +1,13 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !online — lists currently-connected teammates. /// The live server query. /// The reply localizer. -internal sealed class OnlineCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler +internal sealed class OnlineCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler { /// public string Name => "online"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs index eda2e4b..0d4e9bd 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/PopCommandHandler.cs @@ -1,13 +1,13 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !pop — reports current population, slot cap and queue. /// The live server query. /// The reply localizer. -internal sealed class PopCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler +internal sealed class PopCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler { /// public string Name => "pop"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs index f7ce298..da6e3ce 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/ProxCommandHandler.cs @@ -1,14 +1,14 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !prox [name] — distance from the caller to each other teammate (optionally one). /// The live server query. /// The reply localizer. -internal sealed class ProxCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler +internal sealed class ProxCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler { /// public string Name => "prox"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs b/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs index 40b9d18..e83c929 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/RigReply.cs @@ -1,8 +1,8 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; @@ -21,7 +21,7 @@ public static string For( CommandContext context, RigKind rig, string prefix, - ICommandLocalizer localizer) + ILocalizer localizer) { var state = rigState.Get(context.GuildId, context.ServerId, rig); return state.Status switch diff --git a/src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs index d1f83a7..442b500 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/SmallCommandHandler.cs @@ -1,14 +1,14 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !small — reports the small oil rig's status. /// The rig state reader. /// The reply localizer. -internal sealed class SmallCommandHandler(IRigState rigState, ICommandLocalizer localizer) : ICommandHandler +internal sealed class SmallCommandHandler(IRigState rigState, ILocalizer localizer) : ICommandHandler { /// public string Name => "small"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs index 1c7a009..11f64a5 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/SteamIdCommandHandler.cs @@ -2,14 +2,14 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !steamid [name] — reports teammates' Steam ids (all, or filtered by partial name). /// The live server query. /// The reply localizer. -internal sealed class SteamIdCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler +internal sealed class SteamIdCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler { /// public string Name => "steamid"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs index c9fa0f5..66135ce 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/TeamCommandHandler.cs @@ -1,13 +1,13 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !team — lists every team member's name. /// The live server query. /// The reply localizer. -internal sealed class TeamCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler +internal sealed class TeamCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler { /// public string Name => "team"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs index 62ba061..0b0361e 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/TimeCommandHandler.cs @@ -1,14 +1,14 @@ using System.Globalization; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !time — reports the in-game clock and whether it is day or night. /// The live server query. /// The reply localizer. -internal sealed class TimeCommandHandler(IRustServerQuery query, ICommandLocalizer localizer) : ICommandHandler +internal sealed class TimeCommandHandler(IRustServerQuery query, ILocalizer localizer) : ICommandHandler { /// public string Name => "time"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs index cd9200b..b39b223 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/UnmuteCommandHandler.cs @@ -1,5 +1,5 @@ using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Commands; namespace RustPlusBot.Features.Commands.Handlers; @@ -7,7 +7,7 @@ namespace RustPlusBot.Features.Commands.Handlers; /// !unmute — re-enable bot-to-game output. /// The mute store. /// The reply localizer. -internal sealed class UnmuteCommandHandler(IMuteStore store, ICommandLocalizer localizer) : ICommandHandler +internal sealed class UnmuteCommandHandler(IMuteStore store, ILocalizer localizer) : ICommandHandler { /// public string Name => "unmute"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs index 2d338fb..68553ae 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/UptimeCommandHandler.cs @@ -1,14 +1,14 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; using RustPlusBot.Features.Commands.Hosting; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; /// !uptime — reports how long the bot process has been running. /// The process-uptime baseline. /// The reply localizer. -internal sealed class UptimeCommandHandler(BotUptime uptime, ICommandLocalizer localizer) : ICommandHandler +internal sealed class UptimeCommandHandler(BotUptime uptime, ILocalizer localizer) : ICommandHandler { /// public string Name => "uptime"; diff --git a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs index d956ed8..e6ce1f6 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/WipeCommandHandler.cs @@ -2,7 +2,7 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Handlers; @@ -10,7 +10,7 @@ namespace RustPlusBot.Features.Commands.Handlers; /// The live server query. /// The reply localizer. /// The clock used to compute the elapsed time since wipe. -internal sealed class WipeCommandHandler(IRustServerQuery query, ICommandLocalizer localizer, IClock clock) +internal sealed class WipeCommandHandler(IRustServerQuery query, ILocalizer localizer, IClock clock) : ICommandHandler { /// diff --git a/src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs b/src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs index 587a2de..a50f343 100644 --- a/src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs @@ -1,12 +1,12 @@ using System.Globalization; using Discord; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Help; /// Builds the /help embed from the catalog, the server prefix, and the guild culture. /// Resolves the title, group headings, and per-command descriptions. -internal sealed class HelpEmbedRenderer(ICommandLocalizer localizer) +internal sealed class HelpEmbedRenderer(ILocalizer localizer) { private static readonly (CommandGroup Group, string HeadingKey)[] GroupOrder = [ diff --git a/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs b/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs index 41063d7..b8008bf 100644 --- a/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs +++ b/src/RustPlusBot.Features.Commands/Leader/LeaderService.cs @@ -1,5 +1,5 @@ using RustPlusBot.Abstractions.Connections; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Leader; @@ -17,7 +17,7 @@ internal sealed record LeaderTeamResult(IReadOnlyList Member /// The testable logic behind /leader: fetch live members, then promote one. /// The live-server query seam. /// Resolves the result/error messages. -internal sealed class LeaderService(IRustServerQuery query, ICommandLocalizer localizer) +internal sealed class LeaderService(IRustServerQuery query, ILocalizer localizer) { /// Fetches the current team for the promote select, or an error message. /// The owning guild snowflake. diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs deleted file mode 100644 index cf74ea3..0000000 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs +++ /dev/null @@ -1,185 +0,0 @@ -namespace RustPlusBot.Features.Commands.Localization; - -/// The in-memory string catalog for command replies: culture -> (key -> value). English is the fallback. -internal sealed class CommandLocalizationCatalog -{ - /// culture -> key -> value. - public required IReadOnlyDictionary> Strings { get; init; } - - /// The built-in EN/FR catalog. - public static CommandLocalizationCatalog Default { get; } = new() - { - Strings = new Dictionary>(StringComparer.Ordinal) - { - ["en"] = new Dictionary(StringComparer.Ordinal) - { - ["command.mute.done"] = "Bot muted.", - ["command.unmute.done"] = "Bot unmuted.", - ["command.uptime.ok"] = "Uptime: {0}", - ["command.pop.ok"] = "Pop: {0}/{1} ({2} queued)", - ["command.time.ok"] = "Time: {0} — {1}", - ["command.time.day"] = "day", - ["command.time.night"] = "night", - ["command.wipe.ok"] = "Wiped {0} ago", - ["command.notconnected"] = "Not connected to the server.", - ["command.wipe.unknown"] = "Wipe time is unknown.", - ["command.online.ok"] = "Online ({0}): {1}", - ["command.online.none"] = "No one is online.", - ["command.offline.ok"] = "Offline ({0}): {1}", - ["command.offline.none"] = "Everyone is online.", - ["command.team.ok"] = "Team ({0}): {1}", - ["command.team.none"] = "No team members.", - ["command.team.nomatch"] = "No teammate matches '{0}'.", - ["command.steamid.ok"] = "{0}", - ["command.afk.ok"] = "AFK: {0}", - ["command.afk.none"] = "Nobody is AFK.", - ["command.afk.member"] = "{0} ({1})", - ["command.alive.ok"] = "Alive: {0}", - ["command.alive.dead"] = "{0} dead", - ["command.alive.member"] = "{0} {1}", - ["command.prox.ok"] = "Prox: {0}", - ["command.prox.member"] = "{0} {1}m", - ["command.prox.selfunknown"] = "Can't locate you.", - ["command.prox.alone"] = "No teammates nearby.", - ["command.cargo.ok"] = "Cargo Ship at {0} ({1} ago)", - ["command.cargo.none"] = "No cargo ship on the map.", - ["command.heli.ok"] = "Patrol Helicopter at {0} ({1} ago)", - ["command.heli.none"] = "No patrol helicopter on the map.", - ["command.chinook.ok"] = "Chinook at {0} ({1} ago)", - ["command.chinook.none"] = "No chinook on the map.", - ["command.events.ok"] = "Recent: {0}", - ["command.events.none"] = "No recent events.", - ["command.event.cargoentered"] = "cargo in {0}", - ["command.event.cargoleft"] = "cargo left {0}", - ["command.event.helientered"] = "heli in {0}", - ["command.event.helileft"] = "heli left {0}", - ["command.event.chinookspawned"] = "chinook in {0}", - ["command.small.online"] = "Small Oil Rig: crate ready, waiting for activation.", - ["command.small.active"] = "Small Oil Rig: combat phase — crate lootable in {0}.", - ["command.small.offline"] = "Small Oil Rig: looted / dormant — respawns in {0}.", - ["command.large.online"] = "Large Oil Rig: crate ready, waiting for activation.", - ["command.large.active"] = "Large Oil Rig: combat phase — crate lootable in {0}.", - ["command.large.offline"] = "Large Oil Rig: looted / dormant — respawns in {0}.", - ["command.server.none"] = "No server is set up yet.", - ["command.server.specify"] = "Multiple servers are set up — choose one with the server option.", - ["command.server.unknown"] = "That server isn't set up.", - ["help.title"] = "Commands", - ["help.group.control"] = "Control", - ["help.group.server"] = "Server", - ["help.group.teamintel"] = "Team", - ["help.group.bot"] = "Bot", - ["help.group.slash"] = "Slash commands", - ["help.prefixnote"] = "Prefixes are configured per server; showing the default.", - ["help.mute"] = "Silence all bot output to the game.", - ["help.unmute"] = "Resume bot output to the game.", - ["help.pop"] = "Show the server population.", - ["help.time"] = "Show the in-game time.", - ["help.wipe"] = "Show how long ago the server wiped.", - ["help.online"] = "List online teammates.", - ["help.offline"] = "List offline teammates.", - ["help.team"] = "List all team members.", - ["help.steamid"] = "Show teammates' Steam IDs.", - ["help.alive"] = "Show how long each teammate has survived.", - ["help.afk"] = "List teammates who are AFK.", - ["help.prox"] = "Show distance to each teammate.", - ["help.uptime"] = "Show how long the bot has been running.", - ["help.slash.help"] = "Show this help message.", - ["help.slash.uptime"] = "Show the bot's uptime.", - ["help.slash.leader"] = "Transfer in-game team leadership.", - ["uptime.ok"] = "Bot uptime: {0}", - ["leader.noserver"] = "No server is set up yet.", - ["leader.notconnected"] = "Not connected to this server.", - ["leader.noteam"] = "No team members to promote.", - ["leader.pickserver"] = "Choose a server:", - ["leader.pickmember"] = "Choose who to promote to team leader:", - ["leader.success"] = "Promoted {0} to team leader.", - ["leader.failure"] = "Couldn't promote — the server may be unreachable.", - }, - ["fr"] = new Dictionary(StringComparer.Ordinal) - { - ["command.mute.done"] = "Bot mis en sourdine.", - ["command.unmute.done"] = "Bot réactivé.", - ["command.uptime.ok"] = "Disponibilité : {0}", - ["command.pop.ok"] = "Population : {0}/{1} ({2} en file)", - ["command.time.ok"] = "Heure : {0} — {1}", - ["command.time.day"] = "jour", - ["command.time.night"] = "nuit", - ["command.wipe.ok"] = "Wipe il y a {0}", - ["command.notconnected"] = "Non connecté au serveur.", - ["command.wipe.unknown"] = "Heure de wipe inconnue.", - ["command.online.ok"] = "En ligne ({0}) : {1}", - ["command.online.none"] = "Personne n'est en ligne.", - ["command.offline.ok"] = "Hors ligne ({0}) : {1}", - ["command.offline.none"] = "Tout le monde est en ligne.", - ["command.team.ok"] = "Équipe ({0}) : {1}", - ["command.team.none"] = "Aucun membre d'équipe.", - ["command.team.nomatch"] = "Aucun coéquipier ne correspond à « {0} ».", - ["command.steamid.ok"] = "{0}", - ["command.afk.ok"] = "AFK : {0}", - ["command.afk.none"] = "Personne n'est AFK.", - ["command.afk.member"] = "{0} ({1})", - ["command.alive.ok"] = "En vie : {0}", - ["command.alive.dead"] = "{0} mort", - ["command.alive.member"] = "{0} {1}", - ["command.prox.ok"] = "Prox : {0}", - ["command.prox.member"] = "{0} {1}m", - ["command.prox.selfunknown"] = "Impossible de vous localiser.", - ["command.prox.alone"] = "Aucun coéquipier à proximité.", - ["command.cargo.ok"] = "Cargo en {0} (il y a {1})", - ["command.cargo.none"] = "Aucun cargo sur la carte.", - ["command.heli.ok"] = "Hélicoptère en {0} (il y a {1})", - ["command.heli.none"] = "Aucun hélicoptère sur la carte.", - ["command.chinook.ok"] = "Chinook en {0} (il y a {1})", - ["command.chinook.none"] = "Aucun chinook sur la carte.", - ["command.events.ok"] = "Récent : {0}", - ["command.events.none"] = "Aucun événement récent.", - ["command.event.cargoentered"] = "cargo en {0}", - ["command.event.cargoleft"] = "cargo parti de {0}", - ["command.event.helientered"] = "heli en {0}", - ["command.event.helileft"] = "heli parti de {0}", - ["command.event.chinookspawned"] = "chinook en {0}", - ["command.small.online"] = "Petite plateforme : caisse prête, en attente d'activation.", - ["command.small.active"] = "Petite plateforme : phase de combat — caisse lootable dans {0}.", - ["command.small.offline"] = "Petite plateforme : pillée / dormante — réapparaît dans {0}.", - ["command.large.online"] = "Grande plateforme : caisse prête, en attente d'activation.", - ["command.large.active"] = "Grande plateforme : phase de combat — caisse lootable dans {0}.", - ["command.large.offline"] = "Grande plateforme : pillée / dormante — réapparaît dans {0}.", - ["command.server.none"] = "Aucun serveur n'est encore configuré.", - ["command.server.specify"] = - "Plusieurs serveurs sont configurés — choisissez-en un avec l'option serveur.", - ["command.server.unknown"] = "Ce serveur n'est pas configuré.", - ["help.title"] = "Commandes", - ["help.group.control"] = "Contrôle", - ["help.group.server"] = "Serveur", - ["help.group.teamintel"] = "Équipe", - ["help.group.bot"] = "Bot", - ["help.group.slash"] = "Commandes slash", - ["help.prefixnote"] = "Les préfixes sont configurés par serveur ; affichage du préfixe par défaut.", - ["help.mute"] = "Couper toute sortie du bot vers le jeu.", - ["help.unmute"] = "Réactiver la sortie du bot vers le jeu.", - ["help.pop"] = "Afficher la population du serveur.", - ["help.time"] = "Afficher l'heure en jeu.", - ["help.wipe"] = "Afficher depuis combien de temps le serveur a été wipe.", - ["help.online"] = "Lister les coéquipiers en ligne.", - ["help.offline"] = "Lister les coéquipiers hors ligne.", - ["help.team"] = "Lister tous les membres de l'équipe.", - ["help.steamid"] = "Afficher les identifiants Steam des coéquipiers.", - ["help.alive"] = "Afficher depuis combien de temps chaque coéquipier survit.", - ["help.afk"] = "Lister les coéquipiers qui sont AFK.", - ["help.prox"] = "Afficher la distance à chaque coéquipier.", - ["help.uptime"] = "Afficher depuis combien de temps le bot fonctionne.", - ["help.slash.help"] = "Afficher ce message d'aide.", - ["help.slash.uptime"] = "Afficher la disponibilité du bot.", - ["help.slash.leader"] = "Transférer le rôle de chef d'équipe en jeu.", - ["uptime.ok"] = "Disponibilité du bot : {0}", - ["leader.noserver"] = "Aucun serveur n'est encore configuré.", - ["leader.notconnected"] = "Non connecté à ce serveur.", - ["leader.noteam"] = "Aucun membre d'équipe à promouvoir.", - ["leader.pickserver"] = "Choisissez un serveur :", - ["leader.pickmember"] = "Choisissez qui promouvoir chef d'équipe :", - ["leader.success"] = "{0} promu chef d'équipe.", - ["leader.failure"] = "Impossible de promouvoir — le serveur est peut-être injoignable.", - }, - }, - }; -} diff --git a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs b/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs deleted file mode 100644 index 8acc4cf..0000000 --- a/src/RustPlusBot.Features.Commands/Localization/CommandLocalizer.cs +++ /dev/null @@ -1,8 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Commands.Localization; - -/// In-game reply localizer backed by the shared . -/// The string catalog. -internal sealed class CommandLocalizer(CommandLocalizationCatalog catalog) - : DictionaryLocalizer(catalog.Strings), ICommandLocalizer; diff --git a/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs b/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs deleted file mode 100644 index 0bc28d1..0000000 --- a/src/RustPlusBot.Features.Commands/Localization/ICommandLocalizer.cs +++ /dev/null @@ -1,6 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Commands.Localization; - -/// Resolves localized in-game reply strings by key and culture, falling back to English. -internal interface ICommandLocalizer : ILocalizer; diff --git a/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs index b314e64..1e99ac8 100644 --- a/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/CommandSurfaceModule.cs @@ -6,7 +6,7 @@ using RustPlusBot.Features.Commands.Help; using RustPlusBot.Features.Commands.Hosting; using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Commands; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; @@ -76,7 +76,7 @@ public async Task UptimeAsync() await using (scope.ConfigureAwait(false)) { var workspace = scope.ServiceProvider.GetRequiredService(); - var localizer = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); var uptime = scope.ServiceProvider.GetRequiredService(); var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); @@ -103,7 +103,7 @@ public async Task LeaderAsync() var workspace = scope.ServiceProvider.GetRequiredService(); var servers = scope.ServiceProvider.GetRequiredService(); var leader = scope.ServiceProvider.GetRequiredService(); - var localizer = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); @@ -137,7 +137,7 @@ await ShowMemberSelectAsync(scope.ServiceProvider, leader, localizer, Context.Gu private async Task ShowMemberSelectAsync( IServiceProvider provider, LeaderService leader, - ICommandLocalizer localizer, + ILocalizer localizer, ulong guildId, Guid serverId, string culture) diff --git a/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs b/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs index 5184719..df6c268 100644 --- a/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/LeaderComponentModule.cs @@ -4,7 +4,7 @@ using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; @@ -39,7 +39,7 @@ public async Task OnServerSelectedAsync(string[] values) { var workspace = scope.ServiceProvider.GetRequiredService(); var leader = scope.ServiceProvider.GetRequiredService(); - var localizer = scope.ServiceProvider.GetRequiredService(); + var localizer = scope.ServiceProvider.GetRequiredService(); var culture = await workspace.GetCultureAsync(Context.Guild.Id).ConfigureAwait(false); var result = await leader.GetMembersAsync(Context.Guild.Id, serverId, culture, CancellationToken.None) diff --git a/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs b/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs index f49855b..4674379 100644 --- a/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs +++ b/src/RustPlusBot.Features.Commands/Servers/ServerResolver.cs @@ -1,4 +1,4 @@ -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; namespace RustPlusBot.Features.Commands.Servers; @@ -6,7 +6,7 @@ namespace RustPlusBot.Features.Commands.Servers; /// Picks the target server for a slash command from an optional (autocompleted) server argument. /// The server service used to list the guild's servers. /// Resolves the error messages. -internal sealed class ServerResolver(IServerService servers, ICommandLocalizer localizer) +internal sealed class ServerResolver(IServerService servers, ILocalizer localizer) { /// Resolves the target server, or returns a localized error. /// The owning guild snowflake. diff --git a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs index e70c041..24ffcc1 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/AfkCommandHandlerTests.cs @@ -1,15 +1,15 @@ using NSubstitute; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Handlers; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Connections.Listening; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests; public sealed class AfkCommandHandlerTests { private readonly IAfkState _afk = Substitute.For(); - private readonly ICommandLocalizer _localizer = new CommandLocalizer(CommandLocalizationCatalog.Default); + private readonly ILocalizer _localizer = new ResxLocalizer(); private static CommandContext Ctx() => new(1, Guid.NewGuid(), "en", 99, "Caller", []); diff --git a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs index c2dc2b8..bc744d3 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs @@ -7,10 +7,10 @@ using RustPlusBot.Discord; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Hosting; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Commands.Servers; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Commands; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; @@ -44,7 +44,7 @@ public void Dispatcher_and_handlers_resolve() // The singletons AddCommands wires must each resolve (guards against an accidentally dropped registration). Assert.NotNull(provider.GetRequiredService()); Assert.NotNull(provider.GetRequiredService()); - Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); using var scope = provider.CreateScope(); Assert.NotNull(scope.ServiceProvider.GetRequiredService()); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs index 89756bb..e19ae1e 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/EventHandlersTests.cs @@ -3,9 +3,9 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Handlers; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests.Handlers; @@ -15,11 +15,11 @@ public sealed class EventHandlersTests private static readonly Guid Server = Guid.NewGuid(); private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 5, 0, TimeSpan.Zero); - private static (IClock Clock, ICommandLocalizer Loc) Deps() + private static (IClock Clock, ILocalizer Loc) Deps() { var clock = Substitute.For(); clock.UtcNow.Returns(Now); - return (clock, new CommandLocalizer(CommandLocalizationCatalog.Default)); + return (clock, new ResxLocalizer()); } private static CommandContext Ctx() => new(Guild, Server, "en", 0UL, string.Empty, []); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs index b095db3..781f530 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/MuteHandlersTests.cs @@ -1,14 +1,14 @@ using NSubstitute; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Handlers; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Commands; namespace RustPlusBot.Features.Commands.Tests.Handlers; public sealed class MuteHandlersTests { - private static readonly ICommandLocalizer Loc = new CommandLocalizer(CommandLocalizationCatalog.Default); + private static readonly ILocalizer Loc = new ResxLocalizer(); private static CommandContext Ctx(string culture = "en") => new(1, Guid.NewGuid(), culture, 7, "alice", []); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs index d6b9ff1..23d3a69 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/QueryHandlersTests.cs @@ -4,13 +4,13 @@ using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Handlers; using RustPlusBot.Features.Commands.Hosting; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests.Handlers; public sealed class QueryHandlersTests { - private static readonly ICommandLocalizer Loc = new CommandLocalizer(CommandLocalizationCatalog.Default); + private static readonly ILocalizer Loc = new ResxLocalizer(); private static CommandContext Ctx() => new(1, Guid.NewGuid(), "en", 7, "alice", []); [Fact] diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/RigCommandHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/RigCommandHandlersTests.cs index 998703f..2ea1b03 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/RigCommandHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/RigCommandHandlersTests.cs @@ -2,8 +2,8 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Handlers; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests.Handlers; @@ -14,8 +14,7 @@ public sealed class RigCommandHandlersTests private static CommandContext Ctx() => new(Guild, Server, "en", 0UL, string.Empty, []); - private static ICommandLocalizer RealLocalizer() => - new CommandLocalizer(CommandLocalizationCatalog.Default); + private static ILocalizer RealLocalizer() => new ResxLocalizer(); [Fact] public async Task Small_reports_online_when_untracked() diff --git a/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs index 84afbd0..a13731b 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Handlers/TeamIntelHandlersTests.cs @@ -3,13 +3,13 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; using RustPlusBot.Features.Commands.Handlers; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests.Handlers; public sealed class TeamIntelHandlersTests { - private static readonly ICommandLocalizer Loc = new CommandLocalizer(CommandLocalizationCatalog.Default); + private static readonly ILocalizer Loc = new ResxLocalizer(); private static readonly Guid ServerId = Guid.NewGuid(); private static CommandContext Ctx(params string[] args) => diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs index e19fa6c..ddedcd8 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Help/CommandHelpCatalogTests.cs @@ -1,5 +1,5 @@ using RustPlusBot.Features.Commands.Help; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests.Help; @@ -35,7 +35,7 @@ public void InGameCatalogHasNoEntryWithoutAHandler() [Fact] public void EveryDescriptionKeyResolvesInEnglishAndFrench() { - var loc = new CommandLocalizer(CommandLocalizationCatalog.Default); + var loc = new ResxLocalizer(); foreach (var entry in CommandHelpCatalog.InGame.Concat(CommandHelpCatalog.Slash)) { Assert.NotEqual(entry.DescriptionKey, loc.Get(entry.DescriptionKey, "en")); diff --git a/tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs index 3b88a8c..e917ada 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Help/HelpEmbedRendererTests.cs @@ -1,12 +1,11 @@ using RustPlusBot.Features.Commands.Help; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests.Help; public sealed class HelpEmbedRendererTests { - private static readonly HelpEmbedRenderer Renderer = - new(new CommandLocalizer(CommandLocalizationCatalog.Default)); + private static readonly HelpEmbedRenderer Renderer = new(new ResxLocalizer()); [Fact] public void Render_PrefixesInGameCommands() diff --git a/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs index e85a381..675ae11 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Leader/LeaderServiceTests.cs @@ -1,13 +1,13 @@ using NSubstitute; using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Commands.Leader; -using RustPlusBot.Features.Commands.Localization; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Commands.Tests.Leader; public sealed class LeaderServiceTests { - private static readonly CommandLocalizer Loc = new(CommandLocalizationCatalog.Default); + private static readonly ResxLocalizer Loc = new(); private static readonly Guid Server = Guid.NewGuid(); [Fact] diff --git a/tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs deleted file mode 100644 index 75d542c..0000000 --- a/tests/RustPlusBot.Features.Commands.Tests/Localization/CommandLocalizerTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using RustPlusBot.Features.Commands.Localization; - -namespace RustPlusBot.Features.Commands.Tests.Localization; - -public sealed class CommandLocalizerTests -{ - private static readonly CommandLocalizer Sut = new(CommandLocalizationCatalog.Default); - - [Fact] - public void ReturnsFrench_WhenCultureFr() => - Assert.Equal("Bot mis en sourdine.", Sut.Get("command.mute.done", "fr")); - - [Fact] - public void FallsBackToEnglish_WhenCultureUnknown() => - Assert.Equal("Bot muted.", Sut.Get("command.mute.done", "xx")); - - [Fact] - public void FormatsArgs() => - Assert.Equal("Pop: 5/100 (2 queued)", Sut.Get("command.pop.ok", "en", 5, 100, 2)); - - [Fact] - public void NormalizesRegion() => - Assert.Equal("Bot mis en sourdine.", Sut.Get("command.mute.done", "fr-FR")); -} diff --git a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs index e8c0afa..188c25f 100644 --- a/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs +++ b/tests/RustPlusBot.Features.Commands.Tests/Servers/ServerResolverTests.cs @@ -1,14 +1,14 @@ using NSubstitute; using RustPlusBot.Domain.Servers; -using RustPlusBot.Features.Commands.Localization; using RustPlusBot.Features.Commands.Servers; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; namespace RustPlusBot.Features.Commands.Tests.Servers; public sealed class ServerResolverTests { - private static readonly CommandLocalizer Loc = new(CommandLocalizationCatalog.Default); + private static readonly ResxLocalizer Loc = new(); private static RustServer Server(Guid id, string name) => new() { From 7fbdfe323f7a868b0b7f0683cdf8242cb9f0dc55 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:29:55 +0200 Subject: [PATCH 07/13] refactor(workspace): use shared ILocalizer/ResxLocalizer, drop workspace catalog --- .../Localization/ILocalizer.cs | 4 - .../Localization/LocalizationCatalog.cs | 107 ------------------ .../Localization/Localizer.cs | 6 - .../Messages/InformationMessageRenderer.cs | 2 +- .../Messages/MapControlMessageRenderer.cs | 2 +- .../Messages/ServerInfoMessageRenderer.cs | 2 +- .../Messages/SettingsMessageRenderer.cs | 2 +- .../Messages/SetupMessageRenderer.cs | 2 +- .../Reconciler/WorkspaceReconciler.cs | 2 +- .../WorkspaceServiceCollectionExtensions.cs | 5 +- .../Localization/LocalizerTests.cs | 44 ------- .../MapControlMessageRendererTests.cs | 4 +- .../Messages/RendererTests.cs | 4 +- .../Reconciler/ReconcilerHarness.cs | 6 +- 14 files changed, 15 insertions(+), 177 deletions(-) delete mode 100644 src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs delete mode 100644 src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs delete mode 100644 src/RustPlusBot.Features.Workspace/Localization/Localizer.cs delete mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs b/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs deleted file mode 100644 index 17ff656..0000000 --- a/src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace RustPlusBot.Features.Workspace.Localization; - -/// Resolves localized strings by key and BCP-47 culture, falling back to English. -internal interface ILocalizer : RustPlusBot.Localization.ILocalizer; diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs deleted file mode 100644 index de595b3..0000000 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ /dev/null @@ -1,107 +0,0 @@ -namespace RustPlusBot.Features.Workspace.Localization; - -/// The in-memory string catalog: culture -> (key -> value). English is the fallback. -internal sealed class LocalizationCatalog -{ - private const string ProductName = "RustPlusBot"; - - /// culture -> key -> value. - public required IReadOnlyDictionary> Strings { get; init; } - - /// The built-in EN/FR catalog. - public static LocalizationCatalog Default { get; } = new() - { - Strings = new Dictionary>(StringComparer.Ordinal) - { - ["en"] = new Dictionary(StringComparer.Ordinal) - { - ["category.global.name"] = ProductName, - ["channel.information.name"] = "information", - ["channel.setup.name"] = "setup", - ["channel.settings.name"] = "settings", - ["channel.info.name"] = "info", - ["channel.teamchat.name"] = "teamchat", - ["channel.events.name"] = "events", - ["channel.map.name"] = "map", - ["channel.switches.name"] = "switches", - ["channel.alarms.name"] = "alarms", - ["information.title"] = ProductName, - ["information.body"] = "Connect your Rust+ account in #setup, then pair a server in-game to begin.", - ["information.servers"] = "Servers registered: {0}", - ["setup.title"] = "Connect your Rust+ account", - ["setup.body"] = - "Click **Connect account** below and paste your Rust+ FCM credentials. Then pair a server in-game and its channels appear automatically.", - ["setup.connect.button"] = "Connect account", - ["setup.disconnect.button"] = "Disconnect account", - ["server.info.remove.button"] = "Remove server", - ["settings.title"] = "Settings", - ["settings.body"] = "Configure the bot for this server.", - ["settings.language.label"] = "Language", - ["server.info.endpoint"] = "Endpoint: {0}:{1}", - ["server.info.status.label"] = "Status", - ["server.info.status.connecting"] = "Connecting…", - ["server.info.status.connected"] = "Connected", - ["server.info.status.unreachable"] = "Server unreachable", - ["server.info.status.nocredentials"] = "No working credentials", - ["server.info.player.label"] = "Active player", - ["server.info.players.label"] = "Players online", - ["server.info.team.label"] = "Team", - ["server.info.team.value"] = "{0}/{1} online · leader {2}", - ["server.info.swap.placeholder"] = "Switch active player", - ["server.info.none"] = "—", - ["map.control.header"] = "Map layers — toggle to show/hide:", - ["map.layer.grid"] = "Grid", - ["map.layer.markers"] = "Markers", - ["map.layer.monuments"] = "Monuments", - ["map.layer.vendor"] = "Vendor", - ["map.layer.players"] = "Players", - ["map.layer.rigs"] = "Rigs", - }, - ["fr"] = new Dictionary(StringComparer.Ordinal) - { - ["category.global.name"] = ProductName, - ["channel.information.name"] = "informations", - ["channel.setup.name"] = "configuration", - ["channel.settings.name"] = "parametres", - ["channel.info.name"] = "info", - ["channel.teamchat.name"] = "tchat-equipe", - ["channel.events.name"] = "evenements", - ["channel.map.name"] = "carte", - ["channel.switches.name"] = "interrupteurs", - ["channel.alarms.name"] = "alarmes", - ["information.title"] = ProductName, - ["information.body"] = - "Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.", - ["information.servers"] = "Serveurs enregistres : {0}", - ["setup.title"] = "Connectez votre compte Rust+", - ["setup.body"] = - "Cliquez sur **Connecter le compte** ci-dessous et collez vos identifiants FCM Rust+. Appairez ensuite un serveur en jeu et ses salons apparaitront automatiquement.", - ["setup.connect.button"] = "Connecter le compte", - ["setup.disconnect.button"] = "Déconnecter le compte", - ["server.info.remove.button"] = "Supprimer le serveur", - ["settings.title"] = "Parametres", - ["settings.body"] = "Configurez le bot pour ce serveur.", - ["settings.language.label"] = "Langue", - ["server.info.endpoint"] = "Adresse : {0}:{1}", - ["server.info.status.label"] = "État", - ["server.info.status.connecting"] = "Connexion…", - ["server.info.status.connected"] = "Connecté", - ["server.info.status.unreachable"] = "Serveur injoignable", - ["server.info.status.nocredentials"] = "Aucun identifiant valide", - ["server.info.player.label"] = "Joueur actif", - ["server.info.players.label"] = "Joueurs en ligne", - ["server.info.team.label"] = "Équipe", - ["server.info.team.value"] = "{0}/{1} en ligne · chef {2}", - ["server.info.swap.placeholder"] = "Changer le joueur actif", - ["server.info.none"] = "—", - ["map.control.header"] = "Calques de la carte — activer/désactiver :", - ["map.layer.grid"] = "Grille", - ["map.layer.markers"] = "Marqueurs", - ["map.layer.monuments"] = "Monuments", - ["map.layer.vendor"] = "Marchand", - ["map.layer.players"] = "Joueurs", - ["map.layer.rigs"] = "Plateformes", - }, - }, - }; -} diff --git a/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs b/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs deleted file mode 100644 index 805b4cf..0000000 --- a/src/RustPlusBot.Features.Workspace/Localization/Localizer.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RustPlusBot.Features.Workspace.Localization; - -/// Workspace localizer backed by the shared . -/// The string catalog. -internal sealed class Localizer(LocalizationCatalog catalog) - : RustPlusBot.Localization.DictionaryLocalizer(catalog.Strings), ILocalizer; diff --git a/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs index e44810a..d45fe19 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs @@ -1,7 +1,7 @@ using Discord; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; namespace RustPlusBot.Features.Workspace.Messages; diff --git a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs index 27c9070..3c5113e 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/MapControlMessageRenderer.cs @@ -1,7 +1,7 @@ using Discord; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Workspace.Messages; diff --git a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs index 0e69122..2d357e6 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs @@ -4,8 +4,8 @@ using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Servers; diff --git a/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs index 9d7cc34..c8a7668 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs @@ -1,7 +1,7 @@ using Discord; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Workspace.Messages; diff --git a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs index 24bdf22..d6d1653 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs @@ -1,7 +1,7 @@ using Discord; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Workspace.Messages; diff --git a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs index d274939..feab581 100644 --- a/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs +++ b/src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Logging; using RustPlusBot.Domain.Workspace; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.Workspace; diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index 1b85963..9cbcd6e 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -1,13 +1,13 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Discord; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Locating; using RustPlusBot.Features.Workspace.Messages; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Features.Workspace.Specs; using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Workspace; @@ -29,8 +29,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddSingleton(); // Localization. - services.AddSingleton(LocalizationCatalog.Default); - services.AddSingleton(); + services.AddRustPlusBotLocalization(); // Gateway + per-guild lock. services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs deleted file mode 100644 index dd66114..0000000 --- a/tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using RustPlusBot.Features.Workspace.Localization; - -namespace RustPlusBot.Features.Workspace.Tests.Localization; - -public sealed class LocalizerTests -{ - private static Localizer NewLocalizer() => new(LocalizationCatalog.Default); - - [Fact] - public void Get_ReturnsCultureSpecificValue() - { - var localizer = NewLocalizer(); - Assert.Equal("information", localizer.Get("channel.information.name", "en")); - Assert.Equal("informations", localizer.Get("channel.information.name", "fr")); - } - - [Fact] - public void Get_FallsBackToEnglish_WhenCultureMissing() - { - var localizer = NewLocalizer(); - Assert.Equal("information", localizer.Get("channel.information.name", "de")); - } - - [Fact] - public void Get_ReturnsKey_WhenMissingEverywhere() - { - var localizer = NewLocalizer(); - Assert.Equal("nope.key", localizer.Get("nope.key", "en")); - } - - [Fact] - public void Get_NormalizesRegionVariants() - { - var localizer = NewLocalizer(); - Assert.Equal("information", localizer.Get("channel.information.name", "en-US")); - } - - [Fact] - public void Get_WithArgs_Formats() - { - var localizer = NewLocalizer(); - Assert.Contains("3", localizer.Get("information.servers", "en", 3), StringComparison.Ordinal); - } -} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs index 87edc25..3b0591e 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/MapControlMessageRendererTests.cs @@ -1,15 +1,15 @@ using Discord; using NSubstitute; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Messages; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Map; namespace RustPlusBot.Features.Workspace.Tests.Messages; public sealed class MapControlMessageRendererTests { - private static readonly Localizer Loc = new(LocalizationCatalog.Default); + private static readonly ResxLocalizer Loc = new(); [Fact] public async Task Renders_six_toggle_buttons_reflecting_settings() diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 0f3af71..e3f0de7 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -4,9 +4,9 @@ using RustPlusBot.Domain.Connections; using RustPlusBot.Domain.Credentials; using RustPlusBot.Domain.Servers; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Messages; using RustPlusBot.Features.Workspace.Registry; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Servers; using DomainConnectionState = RustPlusBot.Domain.Connections.ConnectionState; @@ -15,7 +15,7 @@ namespace RustPlusBot.Features.Workspace.Tests.Messages; public sealed class RendererTests { - private static readonly Localizer Loc = new(LocalizationCatalog.Default); + private static readonly ResxLocalizer Loc = new(); private static readonly MessageRenderContext Global = new(1, null, "en"); [Fact] diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs index ae76bbe..74d8128 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs @@ -2,10 +2,10 @@ using NSubstitute; using RustPlusBot.Domain.Servers; using RustPlusBot.Features.Workspace.Gateway; -using RustPlusBot.Features.Workspace.Localization; using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Registry; using RustPlusBot.Features.Workspace.Tests.Fakes; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Servers; namespace RustPlusBot.Features.Workspace.Tests.Reconciler; @@ -40,7 +40,7 @@ public WorkspaceReconciler Build() var registry = new WorkspaceRegistry(_channelProviders, _messageProviders); return new WorkspaceReconciler( new WorkspaceBackends(registry, Gateway, Store), _renderers, Servers, - new Localizer(LocalizationCatalog.Default), new ProvisioningLock(), + new ResxLocalizer(), new ProvisioningLock(), NullLogger.Instance); } @@ -82,7 +82,7 @@ public ReconcilerBuilderReusing WithChannel(WorkspaceScope scope, string key, st new WorkspaceBackends(new WorkspaceRegistry(_channelProviders, _messageProviders), source.Gateway, source.Store), _renderers, source.Servers, - new Localizer(LocalizationCatalog.Default), new ProvisioningLock(), + new ResxLocalizer(), new ProvisioningLock(), NullLogger.Instance); private sealed class ListChannelProvider(IEnumerable specs) : IChannelSpecProvider From b1ad4e575deacd803bc62b6ba06ec4520e775b3e Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:32:54 +0200 Subject: [PATCH 08/13] refactor(players): use shared ILocalizer/ResxLocalizer, drop player catalog --- .../PlayerEventServiceCollectionExtensions.cs | 4 +- .../Rendering/IPlayerLocalizer.cs | 6 --- .../Rendering/PlayerEventRenderer.cs | 3 +- .../Rendering/PlayerLocalizationCatalog.cs | 52 ------------------- .../Rendering/PlayerLocalizer.cs | 8 --- .../PlayerEventEndToEndTests.cs | 3 +- .../PlayerEventRelayTests.cs | 3 +- .../PlayerEventRendererTests.cs | 3 +- .../PlayerLocalizationCatalogTests.cs | 40 -------------- 9 files changed, 10 insertions(+), 112 deletions(-) delete mode 100644 src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs delete mode 100644 src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs delete mode 100644 src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs delete mode 100644 tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs diff --git a/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs index 304e840..5642eed 100644 --- a/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Players/PlayerEventServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Players.Posting; using RustPlusBot.Features.Players.Relaying; using RustPlusBot.Features.Players.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Players; @@ -16,8 +17,7 @@ public static IServiceCollection AddPlayers(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - services.AddSingleton(PlayerLocalizationCatalog.Default); - services.AddSingleton(); + services.AddRustPlusBotLocalization(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs deleted file mode 100644 index 818dfdc..0000000 --- a/src/RustPlusBot.Features.Players/Rendering/IPlayerLocalizer.cs +++ /dev/null @@ -1,6 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Players.Rendering; - -/// Resolves localized player-event strings by key and culture, falling back to English. -internal interface IPlayerLocalizer : ILocalizer; diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs index c2babbc..45130cc 100644 --- a/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs +++ b/src/RustPlusBot.Features.Players/Rendering/PlayerEventRenderer.cs @@ -2,12 +2,13 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Players.Rendering; /// Renders one as a Discord embed or an in-game line. /// The reply localizer. -internal sealed class PlayerEventRenderer(IPlayerLocalizer localizer) +internal sealed class PlayerEventRenderer(ILocalizer localizer) { /// Renders the transition for a guild culture as a Discord embed. /// The player transition to render. diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs deleted file mode 100644 index 9b946dd..0000000 --- a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizationCatalog.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace RustPlusBot.Features.Players.Rendering; - -/// The in-memory string catalog for player events: culture -> (key -> value). English is the fallback. -internal sealed class PlayerLocalizationCatalog -{ - /// culture -> key -> value. - public required IReadOnlyDictionary> Strings { get; init; } - - /// The built-in EN/FR catalog. - public static PlayerLocalizationCatalog Default { get; } = new() - { - Strings = new Dictionary>(StringComparer.Ordinal) - { - ["en"] = new Dictionary(StringComparer.Ordinal) - { - ["player.title"] = "Team event", - ["player.connect"] = "🟢 {0} connected", - ["player.connect.line"] = "{0} connected", - ["player.disconnect"] = "🔴 {0} disconnected", - ["player.disconnect.line"] = "{0} disconnected", - ["player.death"] = "💀 {0} died at {1}", - ["player.death.line"] = "{0} died at {1}", - ["player.death.unknown"] = "💀 {0} died", - ["player.death.unknown.line"] = "{0} died", - ["player.respawn"] = "✨ {0} respawned at {1}", - ["player.respawn.line"] = "{0} respawned at {1}", - ["player.afk"] = "💤 {0} is AFK ({1})", - ["player.afk.line"] = "{0} is AFK ({1})", - ["player.afk.back"] = "👋 {0} is back", - ["player.afk.back.line"] = "{0} is back", - }, - ["fr"] = new Dictionary(StringComparer.Ordinal) - { - ["player.title"] = "Événement d'équipe", - ["player.connect"] = "🟢 {0} s'est connecté", - ["player.connect.line"] = "{0} s'est connecté", - ["player.disconnect"] = "🔴 {0} s'est déconnecté", - ["player.disconnect.line"] = "{0} s'est déconnecté", - ["player.death"] = "💀 {0} est mort en {1}", - ["player.death.line"] = "{0} est mort en {1}", - ["player.death.unknown"] = "💀 {0} est mort", - ["player.death.unknown.line"] = "{0} est mort", - ["player.respawn"] = "✨ {0} a réapparu en {1}", - ["player.respawn.line"] = "{0} a réapparu en {1}", - ["player.afk"] = "💤 {0} est AFK ({1})", - ["player.afk.line"] = "{0} est AFK ({1})", - ["player.afk.back"] = "👋 {0} est de retour", - ["player.afk.back.line"] = "{0} est de retour", - }, - }, - }; -} diff --git a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs b/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs deleted file mode 100644 index a841eac..0000000 --- a/src/RustPlusBot.Features.Players/Rendering/PlayerLocalizer.cs +++ /dev/null @@ -1,8 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Players.Rendering; - -/// Player-event localizer backed by the shared . -/// The string catalog. -internal sealed class PlayerLocalizer(PlayerLocalizationCatalog catalog) - : DictionaryLocalizer(catalog.Strings), IPlayerLocalizer; diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs index 3069465..af2dba1 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventEndToEndTests.cs @@ -1,6 +1,7 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Players.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Players.Tests; @@ -9,7 +10,7 @@ public sealed class PlayerEventEndToEndTests [Fact] public void All_transition_kinds_render_without_throwing() { - var renderer = new PlayerEventRenderer(new PlayerLocalizer(PlayerLocalizationCatalog.Default)); + var renderer = new PlayerEventRenderer(new ResxLocalizer()); var dims = new MapDimensions(3000, 3000, 0); foreach (var kind in Enum.GetValues()) { diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs index ec9ddb2..7cf88a1 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRelayTests.cs @@ -7,6 +7,7 @@ using RustPlusBot.Features.Players.Relaying; using RustPlusBot.Features.Players.Rendering; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Players.Tests; @@ -23,7 +24,7 @@ private PlayerEventRelay BuildRelay() var scopeFactory = BuildScopeFactory(_workspace); _workspace.GetCultureAsync(Arg.Any(), Arg.Any()).Returns("en"); return new PlayerEventRelay( - new PlayerEventRenderer(new PlayerLocalizer(PlayerLocalizationCatalog.Default)), + new PlayerEventRenderer(new ResxLocalizer()), _locator, _poster, _sender, scopeFactory); } diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs index 71266a0..1b8e3fd 100644 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs +++ b/tests/RustPlusBot.Features.Players.Tests/PlayerEventRendererTests.cs @@ -1,12 +1,13 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Players.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Players.Tests; public sealed class PlayerEventRendererTests { - private static readonly PlayerEventRenderer Renderer = new(new PlayerLocalizer(PlayerLocalizationCatalog.Default)); + private static readonly PlayerEventRenderer Renderer = new(new ResxLocalizer()); private static readonly MapDimensions Dims = new(3000, 3000, 0); [Fact] diff --git a/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs deleted file mode 100644 index e78ee5c..0000000 --- a/tests/RustPlusBot.Features.Players.Tests/PlayerLocalizationCatalogTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using RustPlusBot.Features.Players.Rendering; - -namespace RustPlusBot.Features.Players.Tests; - -public sealed class PlayerLocalizationCatalogTests -{ - private static readonly string[] Keys = - [ - "player.title", - "player.connect", "player.connect.line", - "player.disconnect", "player.disconnect.line", - "player.death", "player.death.line", - "player.death.unknown", "player.death.unknown.line", - "player.respawn", "player.respawn.line", - "player.afk", "player.afk.line", - "player.afk.back", "player.afk.back.line", - ]; - - [Theory] - [InlineData("en")] - [InlineData("fr")] - public void Every_key_present_for_culture(string culture) - { - var catalog = PlayerLocalizationCatalog.Default; - Assert.True(catalog.Strings.TryGetValue(culture, out var map)); - foreach (var key in Keys) - { - Assert.True(map!.ContainsKey(key), $"missing {key} for {culture}"); - } - } - - [Fact] - public void Localizer_formats_with_args_and_falls_back_to_english() - { - var localizer = new PlayerLocalizer(PlayerLocalizationCatalog.Default); - Assert.Contains("Bob", localizer.Get("player.connect", "en", "Bob"), StringComparison.Ordinal); - // Unknown culture falls back to English string (not the raw key). - Assert.Contains("Bob", localizer.Get("player.connect", "de", "Bob"), StringComparison.Ordinal); - } -} From 38682b19f16ff31eadcd0a01fbfd8415b13934c1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:34:45 +0200 Subject: [PATCH 09/13] refactor(events): use shared ILocalizer/ResxLocalizer, drop event catalog --- .../EventServiceCollectionExtensions.cs | 4 +- .../Rendering/EventEmbedRenderer.cs | 3 +- .../Rendering/EventLocalizationCatalog.cs | 70 ------------------- .../Rendering/EventLocalizer.cs | 8 --- .../Rendering/IEventLocalizer.cs | 6 -- .../Relaying/EventRelayTests.cs | 3 +- .../Rendering/EventEmbedRendererTests.cs | 3 +- .../Rendering/EventLocalizerTests.cs | 28 -------- .../Rendering/RigRenderingTests.cs | 3 +- 9 files changed, 10 insertions(+), 118 deletions(-) delete mode 100644 src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs delete mode 100644 src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs delete mode 100644 src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs delete mode 100644 tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs diff --git a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs index 8f26890..95cc824 100644 --- a/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Events/EventServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using RustPlusBot.Features.Events.Relaying; using RustPlusBot.Features.Events.Rendering; using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Events; @@ -22,8 +23,7 @@ public static IServiceCollection AddEvents(this IServiceCollection services) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(EventLocalizationCatalog.Default); - services.AddSingleton(); + services.AddRustPlusBotLocalization(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs index d100d15..10c52a2 100644 --- a/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Events/Rendering/EventEmbedRenderer.cs @@ -2,12 +2,13 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Events.Rendering; /// Renders one as a Discord embed. /// The reply localizer. -internal sealed class EventEmbedRenderer(IEventLocalizer localizer) +internal sealed class EventEmbedRenderer(ILocalizer localizer) { /// Renders the event for a guild culture. /// The event to render. diff --git a/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs b/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs deleted file mode 100644 index c58ed03..0000000 --- a/src/RustPlusBot.Features.Events/Rendering/EventLocalizationCatalog.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace RustPlusBot.Features.Events.Rendering; - -/// The in-memory string catalog for live events: culture -> (key -> value). English is the fallback. -internal sealed class EventLocalizationCatalog -{ - /// culture -> key -> value. - public required IReadOnlyDictionary> Strings { get; init; } - - /// The built-in EN/FR catalog. - public static EventLocalizationCatalog Default { get; } = new() - { - Strings = new Dictionary>(StringComparer.Ordinal) - { - ["en"] = new Dictionary(StringComparer.Ordinal) - { - ["event.cargo.entered"] = "🚢 Cargo Ship entered at {0}", - ["event.cargo.left"] = "🚢 Cargo Ship left ({0})", - ["event.heli.entered"] = "🚁 Patrol Helicopter entered at {0}", - ["event.heli.left"] = "🚁 Patrol Helicopter left ({0})", - ["event.chinook.spawned"] = "🚁 Chinook spawned at {0}", - ["event.title"] = "Live event", - ["event.cargo.entered.line"] = "Cargo Ship entered at {0}", - ["event.cargo.left.line"] = "Cargo Ship left ({0})", - ["event.heli.entered.line"] = "Patrol Helicopter entered at {0}", - ["event.heli.left.line"] = "Patrol Helicopter left ({0})", - ["event.chinook.spawned.line"] = "Chinook spawned at {0}", - ["event.rig.small.activated"] = "🛢️ Small Oil Rig activated — combat phase, crate lootable soon ({0})", - ["event.rig.small.lootable"] = "🛢️ Small Oil Rig — crate is now LOOTABLE ({0})", - ["event.rig.small.respawned"] = "🛢️ Small Oil Rig — crate respawned, armed again ({0})", - ["event.rig.large.activated"] = "🛢️ Large Oil Rig activated — combat phase, crate lootable soon ({0})", - ["event.rig.large.lootable"] = "🛢️ Large Oil Rig — crate is now LOOTABLE ({0})", - ["event.rig.large.respawned"] = "🛢️ Large Oil Rig — crate respawned, armed again ({0})", - ["event.rig.small.activated.line"] = "Small Oil Rig activated — combat phase ({0})", - ["event.rig.small.lootable.line"] = "Small Oil Rig — crate is now lootable ({0})", - ["event.rig.small.respawned.line"] = "Small Oil Rig — crate respawned ({0})", - ["event.rig.large.activated.line"] = "Large Oil Rig activated — combat phase ({0})", - ["event.rig.large.lootable.line"] = "Large Oil Rig — crate is now lootable ({0})", - ["event.rig.large.respawned.line"] = "Large Oil Rig — crate respawned ({0})", - }, - ["fr"] = new Dictionary(StringComparer.Ordinal) - { - ["event.cargo.entered"] = "🚢 Cargo Ship arrivé en {0}", - ["event.cargo.left"] = "🚢 Cargo Ship parti ({0})", - ["event.heli.entered"] = "🚁 Hélicoptère de patrouille arrivé en {0}", - ["event.heli.left"] = "🚁 Hélicoptère de patrouille parti ({0})", - ["event.chinook.spawned"] = "🚁 Chinook apparu en {0}", - ["event.title"] = "Événement", - ["event.cargo.entered.line"] = "Cargo Ship arrivé en {0}", - ["event.cargo.left.line"] = "Cargo Ship parti ({0})", - ["event.heli.entered.line"] = "Hélicoptère de patrouille arrivé en {0}", - ["event.heli.left.line"] = "Hélicoptère de patrouille parti ({0})", - ["event.chinook.spawned.line"] = "Chinook apparu en {0}", - ["event.rig.small.activated"] = - "🛢️ Petite plateforme pétrolière activée — phase de combat, caisse bientôt lootable ({0})", - ["event.rig.small.lootable"] = "🛢️ Petite plateforme pétrolière — caisse LOOTABLE ({0})", - ["event.rig.small.respawned"] = "🛢️ Petite plateforme pétrolière — caisse réapparue, réarmée ({0})", - ["event.rig.large.activated"] = - "🛢️ Grande plateforme pétrolière activée — phase de combat, caisse bientôt lootable ({0})", - ["event.rig.large.lootable"] = "🛢️ Grande plateforme pétrolière — caisse LOOTABLE ({0})", - ["event.rig.large.respawned"] = "🛢️ Grande plateforme pétrolière — caisse réapparue, réarmée ({0})", - ["event.rig.small.activated.line"] = "Petite plateforme activée — phase de combat ({0})", - ["event.rig.small.lootable.line"] = "Petite plateforme — caisse lootable ({0})", - ["event.rig.small.respawned.line"] = "Petite plateforme — caisse réapparue ({0})", - ["event.rig.large.activated.line"] = "Grande plateforme activée — phase de combat ({0})", - ["event.rig.large.lootable.line"] = "Grande plateforme — caisse lootable ({0})", - ["event.rig.large.respawned.line"] = "Grande plateforme — caisse réapparue ({0})", - }, - }, - }; -} diff --git a/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs b/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs deleted file mode 100644 index 9335d43..0000000 --- a/src/RustPlusBot.Features.Events/Rendering/EventLocalizer.cs +++ /dev/null @@ -1,8 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Events.Rendering; - -/// Live-event localizer backed by the shared . -/// The string catalog. -internal sealed class EventLocalizer(EventLocalizationCatalog catalog) - : DictionaryLocalizer(catalog.Strings), IEventLocalizer; diff --git a/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs b/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs deleted file mode 100644 index c87ddb5..0000000 --- a/src/RustPlusBot.Features.Events/Rendering/IEventLocalizer.cs +++ /dev/null @@ -1,6 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Events.Rendering; - -/// Resolves localized live-event strings by key and culture, falling back to English. -internal interface IEventLocalizer : ILocalizer; diff --git a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs index c2b39ba..70f5310 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Relaying/EventRelayTests.cs @@ -13,6 +13,7 @@ using RustPlusBot.Features.Events.Rendering; using RustPlusBot.Features.Events.State; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Events.Tests.Relaying; @@ -44,7 +45,7 @@ private static (EventRelay Relay, EventStateStore Store, IEventChannelPoster Pos var sender = Substitute.For(); var rigStore = new RigStateStore(clock, Options.Create(new ConnectionOptions())); - var renderer = new EventEmbedRenderer(new EventLocalizer(EventLocalizationCatalog.Default)); + var renderer = new EventEmbedRenderer(new ResxLocalizer()); var relay = new EventRelay( new MarkerEventClassifier(clock), diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs index ce3e982..00d8ccc 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventEmbedRendererTests.cs @@ -1,6 +1,7 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Events.Tests.Rendering; @@ -9,7 +10,7 @@ public sealed class EventEmbedRendererTests private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero); private static EventEmbedRenderer Build() => - new(new EventLocalizer(EventLocalizationCatalog.Default)); + new(new ResxLocalizer()); [Fact] public void Cargo_entered_renders_english_with_grid() diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs deleted file mode 100644 index cc3bd7a..0000000 --- a/tests/RustPlusBot.Features.Events.Tests/Rendering/EventLocalizerTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using RustPlusBot.Features.Events.Rendering; - -namespace RustPlusBot.Features.Events.Tests.Rendering; - -public sealed class EventLocalizerTests -{ - private static readonly EventLocalizer Sut = new(EventLocalizationCatalog.Default); - - [Fact] - public void ReturnsFrench_WhenCultureFr() => - Assert.Equal("🚁 Chinook apparu en {0}", Sut.Get("event.chinook.spawned", "fr")); - - [Fact] - public void ReturnsEnglish_WhenCultureEn() => - Assert.Equal("🚁 Chinook spawned at {0}", Sut.Get("event.chinook.spawned", "en")); - - [Fact] - public void FallsBackToEnglish_WhenCultureUnknown() => - Assert.Equal("🚁 Chinook spawned at {0}", Sut.Get("event.chinook.spawned", "xx")); - - [Fact] - public void ReturnsKey_WhenKeyUnknown() => - Assert.Equal("unknown.key", Sut.Get("unknown.key", "en")); - - [Fact] - public void NormalizesRegion() => - Assert.Equal("🚁 Chinook apparu en {0}", Sut.Get("event.chinook.spawned", "fr-FR")); -} diff --git a/tests/RustPlusBot.Features.Events.Tests/Rendering/RigRenderingTests.cs b/tests/RustPlusBot.Features.Events.Tests/Rendering/RigRenderingTests.cs index f71b51b..feafdf1 100644 --- a/tests/RustPlusBot.Features.Events.Tests/Rendering/RigRenderingTests.cs +++ b/tests/RustPlusBot.Features.Events.Tests/Rendering/RigRenderingTests.cs @@ -1,13 +1,14 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Features.Events.Classifying; using RustPlusBot.Features.Events.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Events.Tests.Rendering; public sealed class RigRenderingTests { private static EventEmbedRenderer Renderer() => - new(new EventLocalizer(EventLocalizationCatalog.Default)); + new(new ResxLocalizer()); [Theory] [InlineData(RigKind.Small, RigEventKind.Activated, "en")] From 92ae8daa48839c640abc2ed77c6f8a1477cf6175 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:37:06 +0200 Subject: [PATCH 10/13] refactor(switches): use shared ILocalizer/ResxLocalizer, drop switch catalog --- .../Rendering/ISwitchLocalizer.cs | 6 --- .../Rendering/SwitchEmbedRenderer.cs | 3 +- .../Rendering/SwitchLocalizationCatalog.cs | 54 ------------------- .../Rendering/SwitchLocalizer.cs | 8 --- .../SwitchServiceCollectionExtensions.cs | 4 +- .../SwitchEmbedRendererTests.cs | 3 +- .../SwitchLocalizationCatalogTests.cs | 33 ------------ .../SwitchPairingCoordinatorTests.cs | 3 +- .../SwitchRegistrationTests.cs | 3 +- .../SwitchStateRelayTests.cs | 3 +- 10 files changed, 12 insertions(+), 108 deletions(-) delete mode 100644 src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs delete mode 100644 src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizationCatalog.cs delete mode 100644 src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs delete mode 100644 tests/RustPlusBot.Features.Switches.Tests/SwitchLocalizationCatalogTests.cs diff --git a/src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs b/src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs deleted file mode 100644 index 39b3454..0000000 --- a/src/RustPlusBot.Features.Switches/Rendering/ISwitchLocalizer.cs +++ /dev/null @@ -1,6 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Switches.Rendering; - -/// Resolves localized smart-switch strings by key and culture, falling back to English. -internal interface ISwitchLocalizer : ILocalizer; diff --git a/src/RustPlusBot.Features.Switches/Rendering/SwitchEmbedRenderer.cs b/src/RustPlusBot.Features.Switches/Rendering/SwitchEmbedRenderer.cs index efdcc70..c7e7a14 100644 --- a/src/RustPlusBot.Features.Switches/Rendering/SwitchEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Switches/Rendering/SwitchEmbedRenderer.cs @@ -1,11 +1,12 @@ using Discord; using RustPlusBot.Domain.Switches; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Switches.Rendering; /// Renders a Smart Switch as a Discord embed + control row, and the pairing-prompt embed + row. Pure. /// The switch localizer. -internal sealed class SwitchEmbedRenderer(ISwitchLocalizer localizer) +internal sealed class SwitchEmbedRenderer(ILocalizer localizer) { /// Renders the switch embed and its control buttons. null ⇒ unreachable. /// The switch. diff --git a/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizationCatalog.cs b/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizationCatalog.cs deleted file mode 100644 index fe050a0..0000000 --- a/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizationCatalog.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace RustPlusBot.Features.Switches.Rendering; - -/// The in-memory string catalog for Smart Switches: culture -> (key -> value). English is the fallback. -internal sealed class SwitchLocalizationCatalog -{ - /// culture -> key -> value. - public required IReadOnlyDictionary> Strings { get; init; } - - /// The built-in EN/FR catalog. - public static SwitchLocalizationCatalog Default { get; } = new() - { - Strings = new Dictionary>(StringComparer.Ordinal) - { - ["en"] = new Dictionary(StringComparer.Ordinal) - { - ["switch.status.on"] = "⚡ ON", - ["switch.status.off"] = "⭘ OFF", - ["switch.status.unreachable"] = "⚠️ Unreachable", - ["switch.button.on"] = "Turn on", - ["switch.button.off"] = "Turn off", - ["switch.button.strobe"] = "Strobe", - ["switch.button.rename"] = "Rename", - ["switch.embed.footer"] = "Entity {0}", - ["switch.prompt.title"] = "New switch detected", - ["switch.prompt.body"] = "Detected a new Smart Switch ({0}). Add it?", - ["switch.prompt.accept"] = "Accept", - ["switch.prompt.dismiss"] = "Dismiss", - ["switch.prompt.dismissed"] = "Dismissed.", - ["switch.rename.modal.title"] = "Rename switch", - ["switch.rename.input.label"] = "Switch name", - ["switch.unreachable.ephemeral"] = "Switch is unreachable right now.", - }, - ["fr"] = new Dictionary(StringComparer.Ordinal) - { - ["switch.status.on"] = "⚡ ALLUMÉ", - ["switch.status.off"] = "⭘ ÉTEINT", - ["switch.status.unreachable"] = "⚠️ Injoignable", - ["switch.button.on"] = "Allumer", - ["switch.button.off"] = "Éteindre", - ["switch.button.strobe"] = "Stroboscope", - ["switch.button.rename"] = "Renommer", - ["switch.embed.footer"] = "Entité {0}", - ["switch.prompt.title"] = "Nouvel interrupteur détecté", - ["switch.prompt.body"] = "Nouvel interrupteur connecté détecté ({0}). L'ajouter ?", - ["switch.prompt.accept"] = "Accepter", - ["switch.prompt.dismiss"] = "Ignorer", - ["switch.prompt.dismissed"] = "Ignoré.", - ["switch.rename.modal.title"] = "Renommer l'interrupteur", - ["switch.rename.input.label"] = "Nom de l'interrupteur", - ["switch.unreachable.ephemeral"] = "L'interrupteur est injoignable pour le moment.", - }, - }, - }; -} diff --git a/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs b/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs deleted file mode 100644 index f9d545f..0000000 --- a/src/RustPlusBot.Features.Switches/Rendering/SwitchLocalizer.cs +++ /dev/null @@ -1,8 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Switches.Rendering; - -/// Smart-switch localizer backed by the shared . -/// The string catalog. -internal sealed class SwitchLocalizer(SwitchLocalizationCatalog catalog) - : DictionaryLocalizer(catalog.Strings), ISwitchLocalizer; diff --git a/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs index fa17e8f..d35a004 100644 --- a/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Switches/SwitchServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using RustPlusBot.Features.Switches.Posting; using RustPlusBot.Features.Switches.Relaying; using RustPlusBot.Features.Switches.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Switches; @@ -18,8 +19,7 @@ public static IServiceCollection AddSwitches(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - services.AddSingleton(SwitchLocalizationCatalog.Default); - services.AddSingleton(); + services.AddRustPlusBotLocalization(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchEmbedRendererTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchEmbedRendererTests.cs index 9e23bfd..51e36ad 100644 --- a/tests/RustPlusBot.Features.Switches.Tests/SwitchEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Switches.Tests/SwitchEmbedRendererTests.cs @@ -1,13 +1,14 @@ using Discord; using RustPlusBot.Domain.Switches; using RustPlusBot.Features.Switches.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Switches.Tests; public sealed class SwitchEmbedRendererTests { private static SwitchEmbedRenderer Create() => - new(new SwitchLocalizer(SwitchLocalizationCatalog.Default)); + new(new ResxLocalizer()); private static SmartSwitch Sample(string name = "Front gate", bool lastActive = false) => new() { diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchLocalizationCatalogTests.cs deleted file mode 100644 index 65976b6..0000000 --- a/tests/RustPlusBot.Features.Switches.Tests/SwitchLocalizationCatalogTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using RustPlusBot.Features.Switches.Rendering; - -namespace RustPlusBot.Features.Switches.Tests; - -public sealed class SwitchLocalizationCatalogTests -{ - [Theory] - [InlineData("en")] - [InlineData("fr")] - public void Catalog_contains_all_required_keys(string culture) - { - var map = SwitchLocalizationCatalog.Default.Strings[culture]; - foreach (var key in new[] - { - "switch.status.on", "switch.status.off", "switch.status.unreachable", "switch.button.on", - "switch.button.off", "switch.button.strobe", "switch.button.rename", "switch.prompt.title", - "switch.prompt.body", "switch.prompt.accept", "switch.prompt.dismiss", "switch.rename.modal.title", - "switch.rename.input.label", "switch.unreachable.ephemeral", - }) - { - Assert.True(map.ContainsKey(key), $"Missing key '{key}' for culture '{culture}'."); - } - } - - [Fact] - public void Localizer_falls_back_to_english() - { - var localizer = new SwitchLocalizer(SwitchLocalizationCatalog.Default); - Assert.Equal( - SwitchLocalizationCatalog.Default.Strings["en"]["switch.status.on"], - localizer.Get("switch.status.on", "de")); - } -} diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchPairingCoordinatorTests.cs index 584e10c..1707d47 100644 --- a/tests/RustPlusBot.Features.Switches.Tests/SwitchPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Switches.Tests/SwitchPairingCoordinatorTests.cs @@ -5,6 +5,7 @@ using RustPlusBot.Features.Switches.Posting; using RustPlusBot.Features.Switches.Rendering; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Switches; using RustPlusBot.Persistence.Workspace; @@ -33,7 +34,7 @@ private static Harness Create() Arg.Any(), Arg.Any()) .Returns(900UL); - var renderer = new SwitchEmbedRenderer(new SwitchLocalizer(SwitchLocalizationCatalog.Default)); + var renderer = new SwitchEmbedRenderer(new ResxLocalizer()); var coordinator = new SwitchPairingCoordinator(scopeFactory, locator, poster, renderer); return new Harness(coordinator, store, poster, locator); } diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchRegistrationTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchRegistrationTests.cs index 464f42b..fd05ee5 100644 --- a/tests/RustPlusBot.Features.Switches.Tests/SwitchRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Switches.Tests/SwitchRegistrationTests.cs @@ -3,6 +3,7 @@ using RustPlusBot.Features.Switches.Pairing; using RustPlusBot.Features.Switches.Relaying; using RustPlusBot.Features.Switches.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Switches.Tests; @@ -17,6 +18,6 @@ public void AddSwitches_registers_core_services() Assert.Contains(services, d => d.ServiceType == typeof(SwitchPairingCoordinator)); Assert.Contains(services, d => d.ServiceType == typeof(SwitchStateRelay)); Assert.Contains(services, d => d.ServiceType == typeof(SwitchEmbedRenderer)); - Assert.Contains(services, d => d.ServiceType == typeof(ISwitchLocalizer)); + Assert.Contains(services, d => d.ServiceType == typeof(ILocalizer)); } } diff --git a/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs b/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs index 9f649df..19578db 100644 --- a/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs +++ b/tests/RustPlusBot.Features.Switches.Tests/SwitchStateRelayTests.cs @@ -7,6 +7,7 @@ using RustPlusBot.Features.Switches.Relaying; using RustPlusBot.Features.Switches.Rendering; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Switches; using RustPlusBot.Persistence.Workspace; @@ -33,7 +34,7 @@ private static Harness Create() var poster = Substitute.For(); poster.EnsureAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns((ulong?)900UL); - var renderer = new SwitchEmbedRenderer(new SwitchLocalizer(SwitchLocalizationCatalog.Default)); + var renderer = new SwitchEmbedRenderer(new ResxLocalizer()); var relay = new SwitchStateRelay(provider.GetRequiredService(), locator, poster, renderer); From 8a436bc56f1ce45b70a1415df39ac897831c53e0 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:39:46 +0200 Subject: [PATCH 11/13] refactor(alarms): use shared ILocalizer/ResxLocalizer, drop alarm catalog --- .../AlarmServiceCollectionExtensions.cs | 3 +- .../Relaying/AlarmStateRelay.cs | 3 +- .../Rendering/AlarmEmbedRenderer.cs | 3 +- .../Rendering/AlarmLocalizationCatalog.cs | 60 ------------------- .../Rendering/AlarmLocalizer.cs | 8 --- .../Rendering/IAlarmLocalizer.cs | 6 -- .../AlarmEmbedRendererTests.cs | 3 +- .../AlarmLocalizationCatalogTests.cs | 48 --------------- .../AlarmPairingCoordinatorTests.cs | 3 +- .../AlarmRefresherTests.cs | 3 +- .../AlarmRegistrationTests.cs | 5 +- .../AlarmStateRelayTests.cs | 3 +- 12 files changed, 17 insertions(+), 131 deletions(-) delete mode 100644 src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs delete mode 100644 src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs delete mode 100644 src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs delete mode 100644 tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs diff --git a/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs index cf1afe2..5cc6806 100644 --- a/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Alarms/AlarmServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using RustPlusBot.Features.Alarms.Posting; using RustPlusBot.Features.Alarms.Relaying; using RustPlusBot.Features.Alarms.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Alarms; @@ -18,7 +19,7 @@ public static IServiceCollection AddAlarms(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - services.AddSingleton(new AlarmLocalizer(AlarmLocalizationCatalog.Default)); + services.AddRustPlusBotLocalization(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs index 1581cb9..7b11def 100644 --- a/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs +++ b/src/RustPlusBot.Features.Alarms/Relaying/AlarmStateRelay.cs @@ -7,6 +7,7 @@ using RustPlusBot.Features.Alarms.Rendering; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Workspace; @@ -36,7 +37,7 @@ internal sealed partial class AlarmStateRelay( IServiceScopeFactory scopeFactory, IAlarmRefresher refresher, AlarmRelayChannels channels, - IAlarmLocalizer localizer, + ILocalizer localizer, IClock clock, ILogger logger) { diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs index d68c26c..09790cf 100644 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs +++ b/src/RustPlusBot.Features.Alarms/Rendering/AlarmEmbedRenderer.cs @@ -2,13 +2,14 @@ using Discord; using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Alarms; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Alarms.Rendering; /// Renders a Smart Alarm as a Discord embed + control row, and the pairing-prompt embed + row. Pure. /// The alarm localizer. /// The clock used to compute relative trigger times. -internal sealed class AlarmEmbedRenderer(IAlarmLocalizer localizer, IClock clock) +internal sealed class AlarmEmbedRenderer(ILocalizer localizer, IClock clock) { /// Renders the alarm embed and its control buttons. /// The alarm to render. diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs deleted file mode 100644 index d13abaf..0000000 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizationCatalog.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace RustPlusBot.Features.Alarms.Rendering; - -/// -/// The in-memory string catalog for Smart Alarms: culture → (key → value). -/// English is the fallback. Intended to be passed to the per-slice -/// constructor. -/// -internal static class AlarmLocalizationCatalog -{ - /// - /// The built-in EN/FR catalog keyed by BCP-47 primary language tag. - /// Shape: culture → key → localized string. - /// - public static IReadOnlyDictionary> Default { get; } = - new Dictionary>(StringComparer.Ordinal) - { - ["en"] = new Dictionary(StringComparer.Ordinal) - { - ["alarm.status.armed"] = "🔔 Armed", - ["alarm.status.active"] = "🚨 Active", - ["alarm.status.unreachable"] = "⚠️ Unreachable", - ["alarm.button.ping.on"] = "Ping @everyone: on", - ["alarm.button.ping.off"] = "Ping @everyone: off", - ["alarm.button.relay.on"] = "Relay to team chat: on", - ["alarm.button.relay.off"] = "Relay to team chat: off", - ["alarm.button.rename"] = "Rename", - ["alarm.embed.footer"] = "Entity {0}", - ["alarm.embed.nevertriggered"] = "Never triggered", - ["alarm.embed.lasttriggered"] = "Last triggered {0} ago", - ["alarm.prompt.title"] = "New alarm detected", - ["alarm.prompt.body"] = "Detected a new Smart Alarm ({0}). Add it?", - ["alarm.prompt.accept"] = "Accept", - ["alarm.prompt.dismiss"] = "Dismiss", - ["alarm.rename.modal.title"] = "Rename alarm", - ["alarm.rename.input.label"] = "Alarm name", - ["alarm.triggered.teamchat"] = "🚨 {0} triggered", - }, - ["fr"] = new Dictionary(StringComparer.Ordinal) - { - ["alarm.status.armed"] = "🔔 Armée", - ["alarm.status.active"] = "🚨 Active", - ["alarm.status.unreachable"] = "⚠️ Injoignable", - ["alarm.button.ping.on"] = "Ping @everyone : activé", - ["alarm.button.ping.off"] = "Ping @everyone : désactivé", - ["alarm.button.relay.on"] = "Relais tchat équipe : activé", - ["alarm.button.relay.off"] = "Relais tchat équipe : désactivé", - ["alarm.button.rename"] = "Renommer", - ["alarm.embed.footer"] = "Entité {0}", - ["alarm.embed.nevertriggered"] = "Jamais déclenchée", - ["alarm.embed.lasttriggered"] = "Déclenchée il y a {0}", - ["alarm.prompt.title"] = "Nouvelle alarme détectée", - ["alarm.prompt.body"] = "Nouvelle alarme connectée détectée ({0}). L'ajouter ?", - ["alarm.prompt.accept"] = "Accepter", - ["alarm.prompt.dismiss"] = "Ignorer", - ["alarm.rename.modal.title"] = "Renommer l'alarme", - ["alarm.rename.input.label"] = "Nom de l'alarme", - ["alarm.triggered.teamchat"] = "🚨 {0} déclenchée", - }, - }; -} diff --git a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs deleted file mode 100644 index 3840c58..0000000 --- a/src/RustPlusBot.Features.Alarms/Rendering/AlarmLocalizer.cs +++ /dev/null @@ -1,8 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Alarms.Rendering; - -/// Smart-alarm localizer backed by the shared . -/// The culture → (key → value) catalog. -internal sealed class AlarmLocalizer(IReadOnlyDictionary> catalog) - : DictionaryLocalizer(catalog), IAlarmLocalizer; diff --git a/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs b/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs deleted file mode 100644 index e7b41ec..0000000 --- a/src/RustPlusBot.Features.Alarms/Rendering/IAlarmLocalizer.cs +++ /dev/null @@ -1,6 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Features.Alarms.Rendering; - -/// Resolves localized smart-alarm strings by key and culture, falling back to English. -internal interface IAlarmLocalizer : ILocalizer; diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs index a1a666a..8b3b971 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmEmbedRendererTests.cs @@ -3,6 +3,7 @@ using RustPlusBot.Abstractions.Time; using RustPlusBot.Domain.Alarms; using RustPlusBot.Features.Alarms.Rendering; +using RustPlusBot.Localization; namespace RustPlusBot.Features.Alarms.Tests; @@ -14,7 +15,7 @@ private static AlarmEmbedRenderer Create(DateTimeOffset? now = null) { var clock = Substitute.For(); clock.UtcNow.Returns(now ?? _fixedNow); - var localizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default); + var localizer = new ResxLocalizer(); return new AlarmEmbedRenderer(localizer, clock); } diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs deleted file mode 100644 index 2736d0c..0000000 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmLocalizationCatalogTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using RustPlusBot.Features.Alarms.Rendering; - -namespace RustPlusBot.Features.Alarms.Tests; - -public sealed class AlarmLocalizationCatalogTests -{ - private static readonly string[] RequiredKeys = - [ - "alarm.status.armed", - "alarm.status.active", - "alarm.status.unreachable", - "alarm.button.ping.on", - "alarm.button.ping.off", - "alarm.button.relay.on", - "alarm.button.relay.off", - "alarm.button.rename", - "alarm.embed.footer", - "alarm.embed.nevertriggered", - "alarm.embed.lasttriggered", - "alarm.prompt.title", - "alarm.prompt.body", - "alarm.prompt.accept", - "alarm.prompt.dismiss", - "alarm.rename.modal.title", - "alarm.rename.input.label", - "alarm.triggered.teamchat", - ]; - - [Theory] - [InlineData("en")] - [InlineData("fr")] - public void Catalog_contains_all_required_keys(string culture) - { - var map = AlarmLocalizationCatalog.Default[culture]; - foreach (var key in RequiredKeys) - { - Assert.True(map.ContainsKey(key), $"Missing key '{key}' for culture '{culture}'."); - } - } - - [Fact] - public void En_and_fr_have_identical_key_sets() - { - var enKeys = new SortedSet(AlarmLocalizationCatalog.Default["en"].Keys); - var frKeys = new SortedSet(AlarmLocalizationCatalog.Default["fr"].Keys); - Assert.Equal(enKeys, frKeys); - } -} diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs index 2353312..f982c10 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmPairingCoordinatorTests.cs @@ -7,6 +7,7 @@ using RustPlusBot.Features.Alarms.Posting; using RustPlusBot.Features.Alarms.Rendering; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Workspace; @@ -37,7 +38,7 @@ private static Harness Create() var clock = Substitute.For(); clock.UtcNow.Returns(new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero)); - var localizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default); + var localizer = new ResxLocalizer(); var renderer = new AlarmEmbedRenderer(localizer, clock); var coordinator = new AlarmPairingCoordinator(scopeFactory, locator, poster, renderer); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs index 7220cda..99bafe7 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRefresherTests.cs @@ -6,6 +6,7 @@ using RustPlusBot.Features.Alarms.Relaying; using RustPlusBot.Features.Alarms.Rendering; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Workspace; @@ -38,7 +39,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL var clock = Substitute.For(); clock.UtcNow.Returns(_fixedNow); - var localizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default); + var localizer = new ResxLocalizer(); var renderer = new AlarmEmbedRenderer(localizer, clock); if (alarm is not null) diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs index 4bd0b39..8f47294 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmRegistrationTests.cs @@ -11,6 +11,7 @@ using RustPlusBot.Features.Alarms.Rendering; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Workspace; @@ -27,7 +28,7 @@ public void AddAlarms_registers_core_services() var services = new ServiceCollection(); services.AddAlarms(); - Assert.Contains(services, d => d.ServiceType == typeof(IAlarmLocalizer)); + Assert.Contains(services, d => d.ServiceType == typeof(ILocalizer)); Assert.Contains(services, d => d.ServiceType == typeof(AlarmEmbedRenderer)); Assert.Contains(services, d => d.ServiceType == typeof(IAlarmChannelPoster)); Assert.Contains(services, d => d.ServiceType == typeof(IAlarmRefresher)); @@ -64,7 +65,7 @@ public void AddAlarms_resolves_without_captive_dependency_errors() using var provider = services.BuildServiceProvider(validateScopes: true); - var localizer = provider.GetRequiredService(); + var localizer = provider.GetRequiredService(); var renderer = provider.GetRequiredService(); var poster = provider.GetRequiredService(); var refresher = provider.GetRequiredService(); diff --git a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs index cb6ec22..7f073e4 100644 --- a/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs +++ b/tests/RustPlusBot.Features.Alarms.Tests/AlarmStateRelayTests.cs @@ -11,6 +11,7 @@ using RustPlusBot.Features.Alarms.Rendering; using RustPlusBot.Features.Connections.Listening; using RustPlusBot.Features.Workspace.Locating; +using RustPlusBot.Localization; using RustPlusBot.Persistence.Alarms; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Workspace; @@ -47,7 +48,7 @@ private static Harness Create(SmartAlarm? alarm = null, ulong? channelId = 777UL .SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(TeamChatSendResult.Sent); - var alarmLocalizer = new AlarmLocalizer(AlarmLocalizationCatalog.Default); + var alarmLocalizer = new ResxLocalizer(); var clock = Substitute.For(); clock.UtcNow.Returns(_fixedNow); From 7fff1978532dbcd4aa47bfd6905a4e7b4004ce39 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:47:45 +0200 Subject: [PATCH 12/13] refactor(localization): remove obsolete DictionaryLocalizer All 6 features now resolve the shared ILocalizer via ResxLocalizer, so the dictionary-backed implementation and its tests are dead code. Behavioral equivalence with the old per-feature catalogs spot-checked across feature prefixes in both cultures (emoji, accents, placeholders byte-identical). Co-Authored-By: Claude Opus 4.8 --- .../DictionaryLocalizer.cs | 61 ------------------- .../DictionaryLocalizerTests.cs | 37 ----------- 2 files changed, 98 deletions(-) delete mode 100644 src/RustPlusBot.Localization/DictionaryLocalizer.cs delete mode 100644 tests/RustPlusBot.Localization.Tests/DictionaryLocalizerTests.cs diff --git a/src/RustPlusBot.Localization/DictionaryLocalizer.cs b/src/RustPlusBot.Localization/DictionaryLocalizer.cs deleted file mode 100644 index f724562..0000000 --- a/src/RustPlusBot.Localization/DictionaryLocalizer.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Globalization; - -namespace RustPlusBot.Localization; - -/// Dictionary-backed with English fallback and region normalization. -/// Culture → (key → value) string table. -public class DictionaryLocalizer( - IReadOnlyDictionary> strings) : ILocalizer -{ - private const string FallbackCulture = "en"; - - /// - public string Get(string key, string culture) - { - var normalized = Normalize(culture); - if (strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value)) - { - return value; - } - - if (strings.TryGetValue(FallbackCulture, out var fallback) && - fallback.TryGetValue(key, out var fallbackValue)) - { - return fallbackValue; - } - - return key; - } - - /// - public string Get(string key, string culture, params object[] args) - { - var format = Get(key, culture); - var provider = ResolveFormatProvider(Normalize(culture)); - return string.Format(provider, format, args); - } - - private static string Normalize(string culture) - { - if (string.IsNullOrWhiteSpace(culture)) - { - return FallbackCulture; - } - - var dash = culture.IndexOf('-', StringComparison.Ordinal); - var primary = dash >= 0 ? culture[..dash] : culture; - return primary.ToLowerInvariant(); - } - - private static CultureInfo ResolveFormatProvider(string culture) - { - try - { - return CultureInfo.GetCultureInfo(culture); - } - catch (CultureNotFoundException) - { - return CultureInfo.InvariantCulture; - } - } -} diff --git a/tests/RustPlusBot.Localization.Tests/DictionaryLocalizerTests.cs b/tests/RustPlusBot.Localization.Tests/DictionaryLocalizerTests.cs deleted file mode 100644 index 61ae923..0000000 --- a/tests/RustPlusBot.Localization.Tests/DictionaryLocalizerTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using RustPlusBot.Localization; - -namespace RustPlusBot.Localization.Tests; - -public sealed class DictionaryLocalizerTests -{ - private static DictionaryLocalizer Build() => new( - new Dictionary>(StringComparer.Ordinal) - { - ["en"] = new Dictionary(StringComparer.Ordinal) - { - ["greet"] = "Hello {0}", ["bye"] = "Bye", - }, - ["fr"] = new Dictionary(StringComparer.Ordinal) - { - ["greet"] = "Bonjour {0}" - }, - }); - - [Fact] - public void Get_ReturnsCultureValue() => Assert.Equal("Bonjour {0}", Build().Get("greet", "fr")); - - [Fact] - public void Get_NormalizesRegion() => Assert.Equal("Bonjour {0}", Build().Get("greet", "fr-FR")); - - [Fact] - public void Get_FallsBackToEnglish() => Assert.Equal("Bye", Build().Get("bye", "fr")); - - [Fact] - public void Get_ReturnsKeyWhenMissing() => Assert.Equal("nope", Build().Get("nope", "en")); - - [Fact] - public void Get_FormatsArgs() => Assert.Equal("Hello world", Build().Get("greet", "en", "world")); - - [Fact] - public void Get_BlankCulture_UsesEnglish() => Assert.Equal("Bye", Build().Get("bye", "")); -} From c90a73287c6b4cda085ff84679dd0bee732a1eb6 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 24 Jun 2026 14:50:41 +0200 Subject: [PATCH 13/13] refactor(commands): share marker-event reply helper across cargo/heli/chinook The cargo/heli/chinook handlers differed only in MarkerKind and key prefix. Extract MarkerReply.For(state, context, kind, prefix, localizer, clock), mirroring the existing RigReply helper; each handler now delegates in one call. Co-Authored-By: Claude Opus 4.8 --- .../Handlers/CargoCommandHandler.cs | 14 +------ .../Handlers/ChinookCommandHandler.cs | 14 +------ .../Handlers/HeliCommandHandler.cs | 14 +------ .../Handlers/MarkerReply.cs | 41 +++++++++++++++++++ 4 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs diff --git a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs index c6a5867..6ca4d35 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/CargoCommandHandler.cs @@ -1,8 +1,6 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; @@ -22,15 +20,7 @@ internal sealed class CargoCommandHandler(IEventState state, ILocalizer localize public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.CargoShip); - if (markers.Count == 0) - { - return Task.FromResult(localizer.Get("command.cargo.none", context.Culture)); - } - - var m = markers[0]; - var grid = GridReference.From(m.X, m.Y, m.Dimensions); - var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); - return Task.FromResult(localizer.Get("command.cargo.ok", context.Culture, grid, ago)); + return Task.FromResult( + MarkerReply.For(state, context, MarkerKind.CargoShip, "command.cargo", localizer, clock)); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs index 6368b22..69999c3 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/ChinookCommandHandler.cs @@ -1,8 +1,6 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; @@ -22,15 +20,7 @@ internal sealed class ChinookCommandHandler(IEventState state, ILocalizer locali public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.Chinook); - if (markers.Count == 0) - { - return Task.FromResult(localizer.Get("command.chinook.none", context.Culture)); - } - - var m = markers[0]; - var grid = GridReference.From(m.X, m.Y, m.Dimensions); - var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); - return Task.FromResult(localizer.Get("command.chinook.ok", context.Culture, grid, ago)); + return Task.FromResult( + MarkerReply.For(state, context, MarkerKind.Chinook, "command.chinook", localizer, clock)); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs index 8ce6116..a12f1c1 100644 --- a/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs +++ b/src/RustPlusBot.Features.Commands/Handlers/HeliCommandHandler.cs @@ -1,8 +1,6 @@ using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Time; using RustPlusBot.Features.Commands.Dispatching; -using RustPlusBot.Features.Commands.Formatting; -using RustPlusBot.Features.Events.Formatting; using RustPlusBot.Features.Events.State; using RustPlusBot.Localization; @@ -22,15 +20,7 @@ internal sealed class HeliCommandHandler(IEventState state, ILocalizer localizer public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(context); - var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, MarkerKind.PatrolHelicopter); - if (markers.Count == 0) - { - return Task.FromResult(localizer.Get("command.heli.none", context.Culture)); - } - - var m = markers[0]; - var grid = GridReference.From(m.X, m.Y, m.Dimensions); - var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); - return Task.FromResult(localizer.Get("command.heli.ok", context.Culture, grid, ago)); + return Task.FromResult( + MarkerReply.For(state, context, MarkerKind.PatrolHelicopter, "command.heli", localizer, clock)); } } diff --git a/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs new file mode 100644 index 0000000..5e40b8f --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Handlers/MarkerReply.cs @@ -0,0 +1,41 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Commands.Dispatching; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Events.Formatting; +using RustPlusBot.Features.Events.State; +using RustPlusBot.Localization; + +namespace RustPlusBot.Features.Commands.Handlers; + +/// Shared map-marker reply formatting for the !cargo/!heli/!chinook handlers. +internal static class MarkerReply +{ + /// Formats the localized reply for the most recent active marker of a kind. + /// The live event state reader. + /// The command context. + /// Which marker kind to report. + /// The localization key prefix ("command.cargo" / "command.heli" / "command.chinook"). + /// The reply localizer. + /// For the "how long ago" suffix. + /// The localized reply. + public static string For( + IEventState state, + CommandContext context, + MarkerKind kind, + string prefix, + ILocalizer localizer, + IClock clock) + { + var markers = state.GetActiveMarkers(context.GuildId, context.ServerId, kind); + if (markers.Count == 0) + { + return localizer.Get($"{prefix}.none", context.Culture); + } + + var m = markers[0]; + var grid = GridReference.From(m.X, m.Y, m.Dimensions); + var ago = DurationFormat.Compact(clock.UtcNow - m.SeenAtUtc); + return localizer.Get($"{prefix}.ok", context.Culture, grid, ago); + } +}