Skip to content

Commit 0b43641

Browse files
HandyS11claude
andcommitted
feat(2a): add EventEmbedRenderer + EN/FR event localization
Implements the pure event rendering layer: EventLocalizationCatalog with EN/FR strings for cargo, helicopter, and Chinook events; IEventLocalizer/EventLocalizer for localization; EventEmbedRenderer to build Discord embeds with grid references. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a867786 commit 0b43641

6 files changed

Lines changed: 211 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Discord;
2+
using RustPlusBot.Features.Events.Classifying;
3+
using RustPlusBot.Features.Events.Formatting;
4+
5+
namespace RustPlusBot.Features.Events.Rendering;
6+
7+
/// <summary>Renders one <see cref="RustMapEvent"/> as a Discord embed.</summary>
8+
/// <param name="localizer">The reply localizer.</param>
9+
internal sealed class EventEmbedRenderer(IEventLocalizer localizer)
10+
{
11+
/// <summary>Renders the event for a guild culture.</summary>
12+
/// <param name="evt">The event to render.</param>
13+
/// <param name="culture">The guild culture ("en"/"fr").</param>
14+
/// <returns>The built embed.</returns>
15+
public Embed Render(RustMapEvent evt, string culture)
16+
{
17+
ArgumentNullException.ThrowIfNull(evt);
18+
var grid = GridReference.From(evt.X, evt.Y, evt.Dimensions);
19+
var key = evt.Kind switch
20+
{
21+
MapEventKind.CargoEntered => "event.cargo.entered",
22+
MapEventKind.CargoLeft => "event.cargo.left",
23+
MapEventKind.HeliEntered => "event.heli.entered",
24+
MapEventKind.HeliLeft => "event.heli.left",
25+
MapEventKind.ChinookSpawned => "event.chinook.spawned",
26+
_ => "event.chinook.spawned",
27+
};
28+
29+
return new EmbedBuilder()
30+
.WithAuthor(localizer.Get("event.title", culture))
31+
.WithDescription(localizer.Get(key, culture, grid))
32+
.WithTimestamp(evt.AtUtc)
33+
.Build();
34+
}
35+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
namespace RustPlusBot.Features.Events.Rendering;
2+
3+
/// <summary>The in-memory string catalog for live events: culture -> (key -> value). English is the fallback.</summary>
4+
internal sealed class EventLocalizationCatalog
5+
{
6+
/// <summary>culture -> key -> value.</summary>
7+
public required IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Strings { get; init; }
8+
9+
/// <summary>The built-in EN/FR catalog.</summary>
10+
public static EventLocalizationCatalog Default { get; } = new()
11+
{
12+
Strings = new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal)
13+
{
14+
["en"] = new Dictionary<string, string>(StringComparer.Ordinal)
15+
{
16+
["event.cargo.entered"] = "🚢 Cargo Ship entered at {0}",
17+
["event.cargo.left"] = "🚢 Cargo Ship left ({0})",
18+
["event.heli.entered"] = "🚁 Patrol Helicopter entered at {0}",
19+
["event.heli.left"] = "🚁 Patrol Helicopter left ({0})",
20+
["event.chinook.spawned"] = "🚁 Chinook spawned at {0}",
21+
["event.title"] = "Live event",
22+
},
23+
["fr"] = new Dictionary<string, string>(StringComparer.Ordinal)
24+
{
25+
["event.cargo.entered"] = "🚢 Cargo Ship arrivé en {0}",
26+
["event.cargo.left"] = "🚢 Cargo Ship parti ({0})",
27+
["event.heli.entered"] = "🚁 Hélicoptère de patrouille arrivé en {0}",
28+
["event.heli.left"] = "🚁 Hélicoptère de patrouille parti ({0})",
29+
["event.chinook.spawned"] = "🚁 Chinook apparu en {0}",
30+
["event.title"] = "Événement",
31+
},
32+
},
33+
};
34+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Globalization;
2+
3+
namespace RustPlusBot.Features.Events.Rendering;
4+
5+
/// <summary>Dictionary-backed <see cref="IEventLocalizer"/> with English fallback and region normalization.</summary>
6+
/// <remarks>Duplicated from the command/workspace localizers; consolidate into a shared project in a future refactor.</remarks>
7+
/// <param name="catalog">The string catalog.</param>
8+
internal sealed class EventLocalizer(EventLocalizationCatalog catalog) : IEventLocalizer
9+
{
10+
private const string FallbackCulture = "en";
11+
12+
/// <inheritdoc />
13+
public string Get(string key, string culture)
14+
{
15+
var normalized = Normalize(culture);
16+
if (catalog.Strings.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
17+
{
18+
return value;
19+
}
20+
21+
if (catalog.Strings.TryGetValue(FallbackCulture, out var fallback) &&
22+
fallback.TryGetValue(key, out var fallbackValue))
23+
{
24+
return fallbackValue;
25+
}
26+
27+
return key;
28+
}
29+
30+
/// <inheritdoc />
31+
public string Get(string key, string culture, params object[] args)
32+
{
33+
var format = Get(key, culture);
34+
var provider = ResolveFormatProvider(Normalize(culture));
35+
return string.Format(provider, format, args);
36+
}
37+
38+
private static string Normalize(string culture)
39+
{
40+
if (string.IsNullOrWhiteSpace(culture))
41+
{
42+
return FallbackCulture;
43+
}
44+
45+
var dash = culture.IndexOf('-', StringComparison.Ordinal);
46+
var primary = dash >= 0 ? culture[..dash] : culture;
47+
return primary.ToLowerInvariant();
48+
}
49+
50+
private static CultureInfo ResolveFormatProvider(string culture)
51+
{
52+
try
53+
{
54+
return CultureInfo.GetCultureInfo(culture);
55+
}
56+
catch (CultureNotFoundException)
57+
{
58+
return CultureInfo.InvariantCulture;
59+
}
60+
}
61+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace RustPlusBot.Features.Events.Rendering;
2+
3+
/// <summary>Resolves localized live-event strings by key and culture, falling back to English.</summary>
4+
internal interface IEventLocalizer
5+
{
6+
/// <summary>Gets the localized string for a key, or the key itself if not found.</summary>
7+
/// <param name="key">The string key.</param>
8+
/// <param name="culture">The BCP-47 culture tag (e.g. "en", "fr").</param>
9+
string Get(string key, string culture);
10+
11+
/// <summary>Gets the localized, format-applied string.</summary>
12+
/// <param name="key">The string key.</param>
13+
/// <param name="culture">The BCP-47 culture tag.</param>
14+
/// <param name="args">Format arguments.</param>
15+
string Get(string key, string culture, params object[] args);
16+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using RustPlusBot.Features.Connections.Listening;
2+
using RustPlusBot.Features.Events.Classifying;
3+
using RustPlusBot.Features.Events.Rendering;
4+
5+
namespace RustPlusBot.Features.Events.Tests.Rendering;
6+
7+
public sealed class EventEmbedRendererTests
8+
{
9+
private static readonly DateTimeOffset Now = new(2026, 6, 17, 12, 0, 0, TimeSpan.Zero);
10+
11+
private static EventEmbedRenderer Build() =>
12+
new(new EventLocalizer(EventLocalizationCatalog.Default));
13+
14+
[Fact]
15+
public void Cargo_entered_renders_english_with_grid()
16+
{
17+
var dims = new MapDimensions(4000u, 4000u, 500);
18+
var embed = Build().Render(new RustMapEvent(MapEventKind.CargoEntered, 10f, 3990f, dims, Now), "en");
19+
20+
Assert.NotNull(embed.Description);
21+
Assert.Contains("Cargo Ship entered", embed.Description, StringComparison.Ordinal);
22+
}
23+
24+
[Fact]
25+
public void Chinook_spawned_renders_french()
26+
{
27+
var embed = Build().Render(new RustMapEvent(MapEventKind.ChinookSpawned, 0f, 0f, null, Now), "fr");
28+
Assert.Contains("Chinook apparu", embed.Description, StringComparison.Ordinal);
29+
}
30+
31+
[Fact]
32+
public void Null_dimensions_render_raw_coordinates()
33+
{
34+
var embed = Build().Render(new RustMapEvent(MapEventKind.HeliEntered, 1234f, 5678f, null, Now), "en");
35+
Assert.Contains("(1234, 5678)", embed.Description, StringComparison.Ordinal);
36+
}
37+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using RustPlusBot.Features.Events.Rendering;
2+
3+
namespace RustPlusBot.Features.Events.Tests.Rendering;
4+
5+
public sealed class EventLocalizerTests
6+
{
7+
private static readonly EventLocalizer Sut = new(EventLocalizationCatalog.Default);
8+
9+
[Fact]
10+
public void ReturnsFrench_WhenCultureFr() =>
11+
Assert.Equal("🚁 Chinook apparu en {0}", Sut.Get("event.chinook.spawned", "fr"));
12+
13+
[Fact]
14+
public void ReturnsEnglish_WhenCultureEn() =>
15+
Assert.Equal("🚁 Chinook spawned at {0}", Sut.Get("event.chinook.spawned", "en"));
16+
17+
[Fact]
18+
public void FallsBackToEnglish_WhenCultureUnknown() =>
19+
Assert.Equal("🚁 Chinook spawned at {0}", Sut.Get("event.chinook.spawned", "xx"));
20+
21+
[Fact]
22+
public void ReturnsKey_WhenKeyUnknown() =>
23+
Assert.Equal("unknown.key", Sut.Get("unknown.key", "en"));
24+
25+
[Fact]
26+
public void NormalizesRegion() =>
27+
Assert.Equal("🚁 Chinook apparu en {0}", Sut.Get("event.chinook.spawned", "fr-FR"));
28+
}

0 commit comments

Comments
 (0)