diff --git a/src/Web/AdminPanel/Pages/CashShopCatalog.razor.cs b/src/Web/AdminPanel/Pages/CashShopCatalog.razor.cs
index e816fc518..d7c001598 100644
--- a/src/Web/AdminPanel/Pages/CashShopCatalog.razor.cs
+++ b/src/Web/AdminPanel/Pages/CashShopCatalog.razor.cs
@@ -4,6 +4,7 @@
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
+using System.Globalization;
using System.Threading;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.QuickGrid;
@@ -63,7 +64,8 @@ public partial class CashShopCatalog : ComponentBase, IAsyncDisposable
private IQueryable
? FilteredPackages => this._configuration?.Packages
.Where(p => !this._onlyForSale || p.IsForSale)
.Where(p => string.IsNullOrWhiteSpace(this._searchText)
- || p.Name.Contains(this._searchText, StringComparison.OrdinalIgnoreCase)
+ || GetPackageName(p).Contains(this._searchText, StringComparison.OrdinalIgnoreCase)
+ || p.Name.ValueInNeutralLanguage.Contains(this._searchText, StringComparison.OrdinalIgnoreCase)
|| p.PackageSequence.ToString() == this._searchText.Trim())
.OrderBy(p => p.PackageSequence)
.AsQueryable();
@@ -100,6 +102,8 @@ protected override async Task OnParametersSetAsync()
await base.OnParametersSetAsync().ConfigureAwait(true);
}
+ private static string GetPackageName(CashShopPackage package) => package.Name.GetTranslation(CultureInfo.CurrentUICulture) ?? string.Empty;
+
private static string GetItemCaption(ItemDefinition? item)
{
return item is null ? string.Empty : $"{item.Name} ({item.Group}, {item.Number})";
diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs
index 905562319..10bccac0e 100644
--- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs
+++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs
@@ -4,7 +4,10 @@
namespace MUnique.OpenMU.Persistence.Initialization.Tests;
+using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Persistence.Initialization.Captions;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence.Initialization.Updates;
using MUnique.OpenMU.Persistence.InMemory;
@@ -21,6 +24,85 @@ internal class CashShopUpdateTest
///
private static readonly int[] DeliveryPackages = [126, 127, 258, 259, 260, 261, 263, 264];
+ ///
+ /// Old package names link by their script number even when database ids differ, without replacing custom names.
+ ///
+ [Test]
+ public async Task PackageNamesLinkAndTranslateWithoutChangingTheCatalogAsync()
+ {
+ var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false);
+ using var _ = context;
+ var packages = gameConfiguration.CashShopConfiguration!.Packages.ToList();
+ var chinese = CultureInfo.GetCultureInfo("zh-CN");
+ Assert.That(packages, Has.All.Matches(p =>
+ p.Name.SourceKey?.StartsWith("CashShopPackageNames/", StringComparison.Ordinal) is true));
+ var reference = CaptionLinkReference.Create(gameConfiguration);
+ var original = packages.ToDictionary(p => p.PackageSequence, p => p.Name);
+ var catalog = packages.Select(p => (p.PackageSequence, p.Price, p.CoinType, p.IsForSale, p.IsGiftable, p.IsBundle, Products: p.Products.ToArray())).ToArray();
+ foreach (var package in packages)
+ {
+ package.SetGuid(Guid.NewGuid());
+ package.Name = new LocalizedString(package.Name.ValueInNeutralLanguage);
+ }
+
+ var customized = packages.Single(p => p.PackageSequence == 3);
+ customized.Name = "Custom ticket";
+ var customTranslation = packages.Single(p => p.PackageSequence == 4);
+ customTranslation.Name = customTranslation.Name.WithTranslation(chinese, "自定义药水");
+ var result = ConfigurationCaptions.LinkSourceKeys(gameConfiguration, reference);
+ Assert.That(result.Linked, Is.EqualTo(204));
+ Assert.That(result.SkippedBecauseOfCustomizedNeutralText, Is.EqualTo(1));
+ Assert.That(customized.Name.SourceKey, Is.Null);
+ Assert.That(customTranslation.Name.GetOwnTranslation(chinese), Is.EqualTo("自定义药水"));
+ Assert.That(packages.Single(p => p.PackageSequence == 6).Name.GetOwnTranslation(chinese), Is.Null);
+ var changes = ConfigurationCaptions.DetermineChanges(gameConfiguration)
+ .Where(change => change.CultureName == chinese.Name && change.IsRecommended).ToList();
+ ConfigurationCaptions.ApplyChanges(gameConfiguration, changes.Select(change => change.Id));
+ foreach (var package in packages.Where(p => p != customized && p != customTranslation))
+ {
+ Assert.That(package.Name.GetOwnTranslation(chinese), Is.EqualTo(original[package.PackageSequence].GetOwnTranslation(chinese)));
+ Assert.That(package.Name.ValueInNeutralLanguage, Is.EqualTo(original[package.PackageSequence].ValueInNeutralLanguage));
+ }
+
+ Assert.That(customTranslation.Name.GetOwnTranslation(chinese), Is.EqualTo("自定义药水"));
+ Assert.That(ConfigurationCaptions.LinkSourceKeys(gameConfiguration, reference).Linked, Is.Zero);
+ Assert.That(ConfigurationCaptions.DetermineChanges(gameConfiguration).Any(change => change.CultureName == chinese.Name && change.IsRecommended), Is.False);
+ Assert.That(packages.Select(p => (p.PackageSequence, p.Price, p.CoinType, p.IsForSale, p.IsGiftable, p.IsBundle, Products: p.Products.ToArray())), Is.EqualTo(catalog));
+ }
+
+ ///
+ /// Duplicate script numbers on either side cannot identify an old package safely.
+ ///
+ /// Whether the reference rather than the target has duplicate numbers.
+ [TestCase(false)]
+ [TestCase(true)]
+ public void DuplicatePackageNumbersAreNotLinked(bool duplicateReference)
+ {
+ var provider = new InMemoryPersistenceContextProvider();
+ using var context = provider.CreateNewContext();
+ GameConfiguration CreateConfiguration(bool reference)
+ {
+ var configuration = context.CreateNew();
+ configuration.CashShopConfiguration = context.CreateNew();
+ for (var i = 0; i < (reference == duplicateReference ? 2 : 1); i++)
+ {
+ var package = context.CreateNew();
+ package.PackageSequence = 6;
+ package.Name = reference
+ ? LocalizedString.FromResource(() => Properties.CashShopPackageNames.SDBoostAura)
+ : new LocalizedString("SD Boost Aura");
+ configuration.CashShopConfiguration.Packages.Add(package);
+ }
+
+ return configuration;
+ }
+
+ var reference = CreateConfiguration(true);
+ var existing = CreateConfiguration(false);
+ Assert.That(ConfigurationCaptions.LinkSourceKeys(existing, reference).Linked, Is.Zero);
+ Assert.That(existing.CashShopConfiguration!.Packages, Has.All.Matches(p => p.Name.SourceKey is null));
+ }
+
///
/// Tests that a freshly initialized database contains the cash shop configuration
/// which matches the cash shop script 512.2012.084 of the game client.
@@ -46,7 +128,7 @@ public async Task FreshDatabaseContainsCashShopAsync()
Assert.That(configuration.Packages.Where(p => DeliveryPackages.Contains(p.PackageSequence)), Has.All.Matches(p => p.IsForSale));
var seal = configuration.Packages.Single(p => p.PackageSequence == 122);
- Assert.That(seal.Name, Is.EqualTo("Seal of Wealth"));
+ Assert.That(seal.Name.ValueInNeutralLanguage, Is.EqualTo("Seal of Wealth"));
Assert.That(seal.CoinType, Is.EqualTo(CashShopCoinType.WCoinC));
Assert.That(seal.IsBundle, Is.False);
Assert.That(seal.Products.Select(p => p.Duration), Is.EquivalentTo(new[] { TimeSpan.FromDays(1), TimeSpan.FromDays(3), TimeSpan.FromDays(7), TimeSpan.FromDays(30) }));
diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationNameResourcesTests.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationNameResourcesTests.cs
index 5e9522bd7..f3bae3e03 100644
--- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationNameResourcesTests.cs
+++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ConfigurationNameResourcesTests.cs
@@ -22,6 +22,7 @@ internal class ConfigurationNameResourcesTests
private static readonly (string Name, ResourceManager ResourceManager)[] Sources =
[
(nameof(CharacterClassNames), CharacterClassNames.ResourceManager),
+ (nameof(CashShopPackageNames), CashShopPackageNames.ResourceManager),
(nameof(MapNames), MapNames.ResourceManager),
(nameof(MerchantNames), MerchantNames.ResourceManager),
(nameof(MonsterNames), MonsterNames.ResourceManager),
diff --git a/tests/MUnique.OpenMU.Tests/CashShopPackagePersistenceTests.cs b/tests/MUnique.OpenMU.Tests/CashShopPackagePersistenceTests.cs
new file mode 100644
index 000000000..8de1a433d
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/CashShopPackagePersistenceTests.cs
@@ -0,0 +1,40 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using System.Globalization;
+using Microsoft.EntityFrameworkCore;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Persistence.EntityFramework;
+
+///
+/// Tests compatibility of the existing package name column with localized names.
+///
+[TestFixture]
+public class CashShopPackagePersistenceTests
+{
+ ///
+ /// The compiled model reads old plain names and preserves translations and source metadata on a round trip.
+ ///
+ [Test]
+ public void NamesUseTheExistingTextColumn()
+ {
+ var entityType = EntityDataContext.CompleteModel.GetEntityTypes().Single(type => type.ClrType.BaseType == typeof(CashShopPackage));
+ var property = entityType.FindProperty(nameof(CashShopPackage.Name))!;
+ Assert.That(property.GetColumnType(), Is.EqualTo("text"));
+ var converter = property.GetTypeMapping().Converter!;
+ var oldName = (LocalizedString)converter.ConvertFromProvider("SD Boost Aura")!;
+ Assert.That(oldName.ValueInNeutralLanguage, Is.EqualTo("SD Boost Aura"));
+ var chinese = CultureInfo.GetCultureInfo("zh-CN");
+ var name = oldName.WithTranslation(chinese, "最大SD提升光环").WithSourceKey("CashShopPackageNames/SDBoostAura").WithSourceStamp();
+ var serialized = converter.ConvertToProvider(name);
+ Assert.That(serialized, Is.EqualTo(name.Value));
+ var restored = (LocalizedString)converter.ConvertFromProvider(serialized)!;
+ Assert.That(restored.Value, Is.EqualTo(name.Value));
+ Assert.That(restored.GetTranslation(chinese), Is.EqualTo("最大SD提升光环"));
+ Assert.That(restored.SourceKey, Is.EqualTo(name.SourceKey));
+ }
+}
diff --git a/tests/MUnique.OpenMU.Web.Tests/Pages/CashShopCatalogPageTests.cs b/tests/MUnique.OpenMU.Web.Tests/Pages/CashShopCatalogPageTests.cs
index 36ae8554d..812fcfd20 100644
--- a/tests/MUnique.OpenMU.Web.Tests/Pages/CashShopCatalogPageTests.cs
+++ b/tests/MUnique.OpenMU.Web.Tests/Pages/CashShopCatalogPageTests.cs
@@ -7,11 +7,13 @@ namespace MUnique.OpenMU.Web.Tests.Pages;
using System.Globalization;
using System.Threading;
using Bunit;
+using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Pages;
using MUnique.OpenMU.Web.AdminPanel.Properties;
@@ -57,6 +59,8 @@ public void Setup()
this._context = new BunitContext();
this._context.JSInterop.Mode = JSRuntimeMode.Loose;
+ this._context.Services.Configure(options => options.AddSupportedUICultures("en", "zh-CN"));
+ this._context.Services.AddSingleton();
this._context.Services.AddSingleton(dataSource.Object);
this._context.Services.AddSingleton(Mock.Of());
this._context.Services.AddSingleton(Mock.Of());
@@ -185,6 +189,44 @@ public void CheckFindsNegativePricesAndInvalidQuantities()
Assert.That(CashShopCatalogCheck.GetIssues(configuration, bundle), Is.EqualTo(new[] { Resources.CashShopIssueNegativeBundlePrice }));
}
+ ///
+ /// The list and search use the UI language, and editing a translation preserves the source and neutral name.
+ ///
+ [Test]
+ public void LocalizedNamesAreDisplayedSearchedAndEdited()
+ {
+ var previousCulture = CultureInfo.CurrentCulture;
+ try
+ {
+ var chinese = CultureInfo.GetCultureInfo("zh-CN");
+ CultureInfo.CurrentUICulture = chinese;
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en");
+ var package = this._gameConfiguration.CashShopConfiguration!.Packages.First(p => p.PackageSequence == 10);
+ package.Name = new LocalizedString("Jewel bundle").WithTranslation(chinese, "宝石礼包").WithSourceKey("Test/JewelBundle");
+ package.Products.First().ItemDefinition!.Name = new LocalizedString("Jewel").WithTranslation(chinese, "宝石").WithSourceKey("Test/Jewel");
+ var cut = this._context.Render();
+ cut.WaitForAssertion(() => Assert.That(cut.Find("table.quickgrid").TextContent, Does.Contain("宝石礼包")));
+ Assert.That(cut.Markup, Does.Not.Contain("@src="));
+ cut.Find("#packageSearch").Input("宝石礼包");
+ cut.WaitForAssertion(() => Assert.That(cut.Find("table.quickgrid").TextContent, Does.Not.Contain("Pet for a day")));
+ cut.Find("#packageSearch").Input("Jewel bundle");
+ cut.WaitForAssertion(() => Assert.That(cut.Find("table.quickgrid").TextContent, Does.Contain("宝石礼包")));
+ cut.FindAll("button").First(b => b.TextContent.Contains(Resources.Edit)).Click();
+ Assert.That(cut.Find("#Name").GetAttribute("value"), Is.EqualTo("Jewel bundle"));
+ Assert.That(cut.Find("#Name_zh-CN").GetAttribute("value"), Is.EqualTo("宝石礼包"));
+ Assert.That(cut.Markup, Does.Not.Contain("@src="));
+ cut.Find("#Name_zh-CN").Change("自定义宝石礼包");
+ cut.Find("form").Submit();
+ Assert.That(package.Name.ValueInNeutralLanguage, Is.EqualTo("Jewel bundle"));
+ Assert.That(package.Name.GetOwnTranslation(chinese), Is.EqualTo("自定义宝石礼包"));
+ Assert.That(package.Name.SourceKey, Is.EqualTo("Test/JewelBundle"));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = previousCulture;
+ }
+ }
+
private static CashShopPackage CreatePackage(int sequence, string name, bool isForSale, ItemDefinition? item, TimeSpan duration)
{
// The ids are set like the persistence context does, because the page lists the packages by them.