From 1a7755b987a9c289fefe17c7720dcd8ae185d70e Mon Sep 17 00:00:00 2001 From: js6pak Date: Wed, 26 Oct 2022 13:08:44 +0200 Subject: [PATCH] Localize Reactor with fluent --- Reactor.Benchmarks/packages.lock.json | 27 +- Reactor.Debugger/packages.lock.json | 27 +- Reactor.Example/packages.lock.json | 27 +- Reactor.Linguinitor/FluentCommentParser.cs | 36 +++ Reactor.Linguinitor/LinguinitorGenerator.cs | 166 +++++++++++ .../Reactor.Linguinitor.csproj | 35 +++ Reactor.Linguinitor/StringExtensions.cs | 38 +++ Reactor.Linguinitor/packages.lock.json | 105 +++++++ Reactor.sln | 6 + Reactor/Assets/Localization/en/messages.ftl | 10 + Reactor/Assets/Localization/nl/messages.ftl | 10 + Reactor/Assets/Localization/pl/messages.ftl | 10 + .../Providers/FluentLocalizationProvider.cs | 258 ++++++++++++++++++ Reactor/Networking/Patches/HttpPatches.cs | 4 +- Reactor/Reactor.csproj | 5 + Reactor/ReactorPlugin.cs | 3 + Reactor/packages.lock.json | 25 +- 17 files changed, 783 insertions(+), 9 deletions(-) create mode 100644 Reactor.Linguinitor/FluentCommentParser.cs create mode 100644 Reactor.Linguinitor/LinguinitorGenerator.cs create mode 100644 Reactor.Linguinitor/Reactor.Linguinitor.csproj create mode 100644 Reactor.Linguinitor/StringExtensions.cs create mode 100644 Reactor.Linguinitor/packages.lock.json create mode 100644 Reactor/Assets/Localization/en/messages.ftl create mode 100644 Reactor/Assets/Localization/nl/messages.ftl create mode 100644 Reactor/Assets/Localization/pl/messages.ftl create mode 100644 Reactor/Localization/Providers/FluentLocalizationProvider.cs diff --git a/Reactor.Benchmarks/packages.lock.json b/Reactor.Benchmarks/packages.lock.json index 0d861b5..5501ebb 100644 --- a/Reactor.Benchmarks/packages.lock.json +++ b/Reactor.Benchmarks/packages.lock.json @@ -161,6 +161,28 @@ "resolved": "2.1.0", "contentHash": "YYpq7NM50bSSVUDjXyV/eiITk6syXqItPjKBOb3jEWS6RFsk0DNhpWMPW6b3hKDmArARuWDU6S2pVu1IeVrvIA==" }, + "Linguini.Bundle": { + "type": "Transitive", + "resolved": "0.3.1", + "contentHash": "k3D0IBSsS91T0G9rYACRYtAS27NDTGz4T3ygHhbhaFdA9i3cCfCpR39MqYEpoA1Ark3lzmvklHePmRCX5EQi1A==", + "dependencies": { + "Linguini.Shared": "0.3.0", + "Linguini.Syntax": "0.3.0" + } + }, + "Linguini.Shared": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "rEYA9jWU1Nc9iDuPKar+4IVXjFbGrcNyYZCeU6hiWQoS4nA2UecHu36Rp4bxzasvRGda4FB2JZqty75m4e26VQ==" + }, + "Linguini.Syntax": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "v8YV3xPFZfP+OSWjMd6yysp+o7cNnQXxq/slN8Yekoi+dFjsTNF0YiQWJIwg1j2FUbs6claJxY9R1FFlc4s6MA==", + "dependencies": { + "Linguini.Shared": "0.3.0" + } + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "1.1.0", @@ -1074,9 +1096,10 @@ "reactor": { "type": "Project", "dependencies": { - "BepInEx.Unity.IL2CPP": "[6.0.0-be.662, )" + "BepInEx.Unity.IL2CPP": "[6.0.0-be.662, )", + "Linguini.Bundle": "[0.3.1, )" } } } } -} +} \ No newline at end of file diff --git a/Reactor.Debugger/packages.lock.json b/Reactor.Debugger/packages.lock.json index e723b5f..288cad9 100644 --- a/Reactor.Debugger/packages.lock.json +++ b/Reactor.Debugger/packages.lock.json @@ -131,6 +131,28 @@ "resolved": "2.1.0", "contentHash": "YYpq7NM50bSSVUDjXyV/eiITk6syXqItPjKBOb3jEWS6RFsk0DNhpWMPW6b3hKDmArARuWDU6S2pVu1IeVrvIA==" }, + "Linguini.Bundle": { + "type": "Transitive", + "resolved": "0.3.1", + "contentHash": "k3D0IBSsS91T0G9rYACRYtAS27NDTGz4T3ygHhbhaFdA9i3cCfCpR39MqYEpoA1Ark3lzmvklHePmRCX5EQi1A==", + "dependencies": { + "Linguini.Shared": "0.3.0", + "Linguini.Syntax": "0.3.0" + } + }, + "Linguini.Shared": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "rEYA9jWU1Nc9iDuPKar+4IVXjFbGrcNyYZCeU6hiWQoS4nA2UecHu36Rp4bxzasvRGda4FB2JZqty75m4e26VQ==" + }, + "Linguini.Syntax": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "v8YV3xPFZfP+OSWjMd6yysp+o7cNnQXxq/slN8Yekoi+dFjsTNF0YiQWJIwg1j2FUbs6claJxY9R1FFlc4s6MA==", + "dependencies": { + "Linguini.Shared": "0.3.0" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", "resolved": "6.0.1", @@ -867,9 +889,10 @@ "reactor": { "type": "Project", "dependencies": { - "BepInEx.Unity.IL2CPP": "[6.0.0-be.662, )" + "BepInEx.Unity.IL2CPP": "[6.0.0-be.662, )", + "Linguini.Bundle": "[0.3.1, )" } } } } -} +} \ No newline at end of file diff --git a/Reactor.Example/packages.lock.json b/Reactor.Example/packages.lock.json index e723b5f..288cad9 100644 --- a/Reactor.Example/packages.lock.json +++ b/Reactor.Example/packages.lock.json @@ -131,6 +131,28 @@ "resolved": "2.1.0", "contentHash": "YYpq7NM50bSSVUDjXyV/eiITk6syXqItPjKBOb3jEWS6RFsk0DNhpWMPW6b3hKDmArARuWDU6S2pVu1IeVrvIA==" }, + "Linguini.Bundle": { + "type": "Transitive", + "resolved": "0.3.1", + "contentHash": "k3D0IBSsS91T0G9rYACRYtAS27NDTGz4T3ygHhbhaFdA9i3cCfCpR39MqYEpoA1Ark3lzmvklHePmRCX5EQi1A==", + "dependencies": { + "Linguini.Shared": "0.3.0", + "Linguini.Syntax": "0.3.0" + } + }, + "Linguini.Shared": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "rEYA9jWU1Nc9iDuPKar+4IVXjFbGrcNyYZCeU6hiWQoS4nA2UecHu36Rp4bxzasvRGda4FB2JZqty75m4e26VQ==" + }, + "Linguini.Syntax": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "v8YV3xPFZfP+OSWjMd6yysp+o7cNnQXxq/slN8Yekoi+dFjsTNF0YiQWJIwg1j2FUbs6claJxY9R1FFlc4s6MA==", + "dependencies": { + "Linguini.Shared": "0.3.0" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", "resolved": "6.0.1", @@ -867,9 +889,10 @@ "reactor": { "type": "Project", "dependencies": { - "BepInEx.Unity.IL2CPP": "[6.0.0-be.662, )" + "BepInEx.Unity.IL2CPP": "[6.0.0-be.662, )", + "Linguini.Bundle": "[0.3.1, )" } } } } -} +} \ No newline at end of file diff --git a/Reactor.Linguinitor/FluentCommentParser.cs b/Reactor.Linguinitor/FluentCommentParser.cs new file mode 100644 index 0000000..11677d8 --- /dev/null +++ b/Reactor.Linguinitor/FluentCommentParser.cs @@ -0,0 +1,36 @@ +using System.Text; +using System.Text.RegularExpressions; +using Linguini.Syntax.Ast; + +namespace Reactor.Linguinitor; + +internal static class FluentCommentParser +{ + private static readonly Regex _variableCommentRegex = new(@"^\$(?[\w-]+) \((?String|Number|Date)\)( - (?.+))?$", RegexOptions.Compiled); + + public static MessageInfo Parse(AstComment astComment) + { + var descriptionBuilder = new StringBuilder(); + var variables = new Dictionary(); + + foreach (var s in astComment.AsStr().Split("\n")) + { + var line = s.Trim(); + + if (_variableCommentRegex.Match(line) is { Success: true } match) + { + variables.Add(match.Groups["id"].Value, new VariableInfo(match.Groups["type"].Value, match.Groups["description"].Value)); + } + else + { + descriptionBuilder.AppendLine(line); + } + } + + return new MessageInfo(descriptionBuilder.ToString(), variables); + } + + public record MessageInfo(string Description, Dictionary Variables); + + public record VariableInfo(string Type, string? Description); +} diff --git a/Reactor.Linguinitor/LinguinitorGenerator.cs b/Reactor.Linguinitor/LinguinitorGenerator.cs new file mode 100644 index 0000000..8eae0e7 --- /dev/null +++ b/Reactor.Linguinitor/LinguinitorGenerator.cs @@ -0,0 +1,166 @@ +using CSharpPoet; +using Linguini.Syntax.Ast; +using Linguini.Syntax.Parser; +using Microsoft.CodeAnalysis; + +namespace Reactor.Linguinitor; + +[Generator(LanguageNames.CSharp)] +public class LinguinitorGenerator : IIncrementalGenerator +{ + private static CSharpFile CreateTranslateFile(string @namespace, params CSharpType.IMember[] members) + { + return new CSharpFile(@namespace) + { + new CSharpClass(Visibility.Internal, "Translate") + { + IsStatic = true, + IsPartial = true, + Members = members, + }, + }; + } + + private static IEnumerable GetVariables(AstMessage astMessage) + { + foreach (var placeable in astMessage.Value!.Elements.OfType()) + { + switch (placeable.Expression) + { + case VariableReference variableReference: + yield return variableReference.Id; + break; + } + } + } + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var rootNamespaceProvider = + context.AnalyzerConfigOptionsProvider + .Select((options, _) => options.GlobalOptions.TryGetValue("build_property.RootNamespace", out var rootNamespace) ? rootNamespace : throw new InvalidOperationException("Failed to get RootNamespace")); + + context.RegisterSourceOutput(rootNamespaceProvider, (spc, rootNamespace) => + { + const string ProviderType = "Reactor.Localization.Providers.FluentLocalizationProvider"; + spc.AddSource("Translate", CreateTranslateFile( + rootNamespace, + new CSharpField(Visibility.Private, ProviderType + "?", "_provider") { IsStatic = true }, + new CSharpProperty(ProviderType, "Provider") + { + IsStatic = true, + Getter = new CSharpProperty.Accessor + { + Body = writer => writer.Write("_provider ?? throw new System.InvalidOperationException(\"Provider must be set before using Translate\");"), + }, + Setter = new CSharpProperty.Accessor + { + Body = writer => writer.Write("_provider = value;"), + }, + } + ).ToString()); + }); + + var textFiles = context.AdditionalTextsProvider.Where(static file => file.Path.EndsWith(".ftl", StringComparison.Ordinal)); + + var namesAndContents = textFiles.Select((text, cancellationToken) => ( + Name: Path.GetFileNameWithoutExtension(text.Path), + Content: text.GetText(cancellationToken)!.ToString() + )); + + context.RegisterSourceOutput(namesAndContents.Combine(rootNamespaceProvider), (spc, pair) => + { + var ((name, content), rootNamespace) = pair; + var fixedName = name.ToPascalCase(); + + var idsClass = new CSharpClass("Ids") + { + IsStatic = true, + }; + + var fileClass = new CSharpClass(fixedName) + { + IsStatic = true, + Members = { idsClass }, + }; + + var resource = new LinguiniParser(content).ParseWithComments(); + + foreach (var message in resource.Entries.OfType()) + { + var id = message.GetId(); + var fixedId = id.ToPascalCase(); + + idsClass.Members.Add(new CSharpField(Visibility.Public, "string", fixedId) + { + IsConst = true, + DefaultValue = $"\"{id}\"", + }); + + var messageInfo = message.Comment != null ? FluentCommentParser.Parse(message.Comment) : null; + var variables = GetVariables(message).Select(m => m.ToString()).ToDictionary( + m => m, + m => + { + if (messageInfo != null && messageInfo.Variables.TryGetValue(m, out var variableInfo)) + { + return variableInfo.Type switch + { + "String" => "string", + "Number" => "double", + _ => throw new NotSupportedException($"Variable type {variableInfo.Type} is not supported"), + }; + } + + return "string"; + } + ); + + void WriteComment(CodeWriter writer) + { + if (messageInfo == null) return; + + writer.WriteLine(""); + + foreach (var line in messageInfo.Description.Trim().Split("\n")) + { + writer.WriteLine(line); + } + + writer.WriteLine(""); + + foreach (var (variableId, info) in messageInfo.Variables) + { + writer.WriteLine($"{info.Description}"); + } + } + + if (variables.Count <= 0) + { + fileClass.Members.Add(new CSharpProperty("string", fixedId) + { + XmlComment = WriteComment, + IsStatic = true, + Getter = new CSharpProperty.Accessor + { + Body = writer => writer.Write($"Provider.GetText(Ids.{fixedId});"), + }, + }); + } + else + { + fileClass.Members.Add(new CSharpMethod("string", fixedId) + { + XmlComment = WriteComment, + IsStatic = true, + Parameters = variables.Select(v => new CSharpParameter(v.Value, v.Key)).ToArray(), + BodyType = BodyType.Expression, + Body = writer => writer.WriteLine($"Provider.GetText(Ids.{fixedId}, {string.Join(", ", variables.Select(v => $"(\"{v.Key}\", {v.Key.ToCamelCase()})"))});"), + }); + } + } + + spc.AddSource("Translate." + fixedName, CreateTranslateFile(rootNamespace, fileClass).ToString()); + }); + } +} diff --git a/Reactor.Linguinitor/Reactor.Linguinitor.csproj b/Reactor.Linguinitor/Reactor.Linguinitor.csproj new file mode 100644 index 0000000..0f70915 --- /dev/null +++ b/Reactor.Linguinitor/Reactor.Linguinitor.csproj @@ -0,0 +1,35 @@ + + + net6.0;net472 + latest + enable + enable + true + + + + + + + + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + <_LinguiniTargetFramework Condition="'$(TargetFramework)' == 'net6.0'">netstandard2.1 + <_LinguiniTargetFramework Condition="'$(TargetFramework)' == 'net472'">net451 + + + + + + + + + diff --git a/Reactor.Linguinitor/StringExtensions.cs b/Reactor.Linguinitor/StringExtensions.cs new file mode 100644 index 0000000..8dcb59a --- /dev/null +++ b/Reactor.Linguinitor/StringExtensions.cs @@ -0,0 +1,38 @@ +using System.Text; + +namespace Reactor.Linguinitor; + +internal static class StringExtensions +{ + private static string ToCase(this string value, bool capitalizeFirst) + { + var stringBuilder = new StringBuilder(); + + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + + if (c == '-') + { + i++; + stringBuilder.Append(char.ToUpperInvariant(value[i])); + } + else + { + stringBuilder.Append(capitalizeFirst && i == 0 ? char.ToUpperInvariant(c) : c); + } + } + + return stringBuilder.ToString(); + } + + public static string ToPascalCase(this string value) + { + return value.ToCase(true); + } + + public static string ToCamelCase(this string value) + { + return value.ToCase(false); + } +} diff --git a/Reactor.Linguinitor/packages.lock.json b/Reactor.Linguinitor/packages.lock.json new file mode 100644 index 0000000..c2672df --- /dev/null +++ b/Reactor.Linguinitor/packages.lock.json @@ -0,0 +1,105 @@ +{ + "version": 1, + "dependencies": { + "net6.0": { + "CSharpPoet": { + "type": "Direct", + "requested": "[0.2.0-dev, )", + "resolved": "0.2.0-dev" + }, + "Linguini.Shared": { + "type": "Direct", + "requested": "[0.3.0, )", + "resolved": "0.3.0", + "contentHash": "rEYA9jWU1Nc9iDuPKar+4IVXjFbGrcNyYZCeU6hiWQoS4nA2UecHu36Rp4bxzasvRGda4FB2JZqty75m4e26VQ==" + }, + "Linguini.Syntax": { + "type": "Direct", + "requested": "[0.3.0, )", + "resolved": "0.3.0", + "contentHash": "v8YV3xPFZfP+OSWjMd6yysp+o7cNnQXxq/slN8Yekoi+dFjsTNF0YiQWJIwg1j2FUbs6claJxY9R1FFlc4s6MA==", + "dependencies": { + "Linguini.Shared": "0.3.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[4.3.1, )", + "resolved": "4.3.1", + "contentHash": "5C9VHvahL98tumlyaT/loB5rW+K/6q0UU7uLyT1Dv15YjZraRkqML9u2t2e8GaO7XqEOtBVqK/SlxXOPqwzxog==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.3.1]" + } + }, + "StyleCop.Analyzers": { + "type": "Direct", + "requested": "[1.2.0-beta.435, )", + "resolved": "1.2.0-beta.435", + "contentHash": "TADk7vdGXtfTnYCV7GyleaaRTQjfoSfZXprQrVMm7cSJtJbFc1QIbWPyLvrgrfGdfHbGmUPvaN4ODKNxg2jgPQ==", + "dependencies": { + "StyleCop.Analyzers.Unstable": "1.2.0.435" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.3", + "contentHash": "j/rOZtLMVJjrfLRlAMckJLPW/1rze9MT1yfWqSIbUPGRu1m1P0fuo9PmqapwsmePfGB5PJrudQLvmUOAMF0DqQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.3.1", + "contentHash": "wexpJffSEEwptwe6UTMxRDZCCtz+XubI4Qewl4JECnNhcQrtb0anhSUEV9Nz7WkoNfWkx1fptR8xh1egoxYrqw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.3", + "System.Collections.Immutable": "6.0.0", + "System.Memory": "4.5.4", + "System.Reflection.Metadata": "5.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "6.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "StyleCop.Analyzers.Unstable": { + "type": "Transitive", + "resolved": "1.2.0.435", + "contentHash": "ouwPWZxbOV3SmCZxIRqHvljkSzkCyi1tDoMzQtDb/bRP8ctASV/iRJr+A2Gdj0QLaLmWnqTWDrH82/iP+X80Lg==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5NecZgXktdGg34rh1OenY1rFNDCI8xSjFr+Z4OU4cU06AQHUdRnIIEeWENu3Wl4YowbzkymAIMvi3WyK9U53pQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" + } + } + } +} \ No newline at end of file diff --git a/Reactor.sln b/Reactor.sln index ba2a0bc..55cf285 100644 --- a/Reactor.sln +++ b/Reactor.sln @@ -18,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Files", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reactor.Networking.Shared", "Reactor.Networking.Shared\Reactor.Networking.Shared.csproj", "{1E68CA20-22EB-4E57-B525-81970BE6363D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reactor.Linguinitor", "Reactor.Linguinitor\Reactor.Linguinitor.csproj", "{B4740F75-B657-4515-A8F6-8A84D64E8814}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,6 +46,10 @@ Global {1E68CA20-22EB-4E57-B525-81970BE6363D}.Debug|Any CPU.Build.0 = Debug|Any CPU {1E68CA20-22EB-4E57-B525-81970BE6363D}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E68CA20-22EB-4E57-B525-81970BE6363D}.Release|Any CPU.Build.0 = Release|Any CPU + {B4740F75-B657-4515-A8F6-8A84D64E8814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4740F75-B657-4515-A8F6-8A84D64E8814}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4740F75-B657-4515-A8F6-8A84D64E8814}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4740F75-B657-4515-A8F6-8A84D64E8814}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection diff --git a/Reactor/Assets/Localization/en/messages.ftl b/Reactor/Assets/Localization/en/messages.ftl new file mode 100644 index 0000000..9f32f44 --- /dev/null +++ b/Reactor/Assets/Localization/en/messages.ftl @@ -0,0 +1,10 @@ +-for-more-info-see = For more info see https://reactor.gg/handshake + +vanilla-server-warning = + This server doesn't support Reactor protocol. + The lobbies shown may not be compatible with your current mods. + { -for-more-info-see } + +public-lobby-warning = + You can't make public lobbies on servers that don't support Reactor protocol. + { -for-more-info-see } diff --git a/Reactor/Assets/Localization/nl/messages.ftl b/Reactor/Assets/Localization/nl/messages.ftl new file mode 100644 index 0000000..906d8e6 --- /dev/null +++ b/Reactor/Assets/Localization/nl/messages.ftl @@ -0,0 +1,10 @@ +-for-more-info-see = Voor meer informatie, zie https://reactor.gg/handshake + +vanilla-server-warning = + Deze server ondersteunt het Reactor protocol niet. + De lobbies die je ziet passen mogelijk niet bij jouw huidige mods. + { -for-more-info-see } + +public-lobby-warning = + Je kan geen publieke lobbies maken op servers die het Reactor protocol niet ondersteunen. + { -for-more-info-see } diff --git a/Reactor/Assets/Localization/pl/messages.ftl b/Reactor/Assets/Localization/pl/messages.ftl new file mode 100644 index 0000000..93cc254 --- /dev/null +++ b/Reactor/Assets/Localization/pl/messages.ftl @@ -0,0 +1,10 @@ +-for-more-info-see = Dla więcej informacji, zobacz https://reactor.gg/handshake + +vanilla-server-warning = + Ten serwer nie wspiera protokołu Reactor. + Pokazane gry mogą nie być kompatibline z twoimi modami. + { -for-more-info-see } + +public-lobby-warning = + Nie możesz tworzyć publicznych gier w serwerach, które nie wspierają protokołu Reactor. + { -for-more-info-see } diff --git a/Reactor/Localization/Providers/FluentLocalizationProvider.cs b/Reactor/Localization/Providers/FluentLocalizationProvider.cs new file mode 100644 index 0000000..ed1d97a --- /dev/null +++ b/Reactor/Localization/Providers/FluentLocalizationProvider.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.InteropTypes; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using Linguini.Bundle; +using Linguini.Bundle.Builder; +using Linguini.Bundle.Errors; +using Linguini.Shared.Types.Bundle; +using Linguini.Syntax.Ast; +using Reactor.Localization.Extensions; +using Reactor.Localization.Utilities; +using Reactor.Utilities; +using Object = Il2CppSystem.Object; + +namespace Reactor.Localization.Providers; + +/// +/// Represents a localization provider that uses Fluent. +/// +public class FluentLocalizationProvider : LocalizationProvider +{ + private readonly Func> _loadResources; + private readonly List _bundles = new(); + private readonly Dictionary _ids = new(); + private readonly Dictionary _arguments = new(); + + /// + /// Gets or sets the fallback language. + /// + public SupportedLangs FallbackLanguage { get; set; } = SupportedLangs.English; + + /// + /// Initializes a new instance of the class. + /// + /// The implementation of resource loading. + public FluentLocalizationProvider(Func> loadResources) + { + _loadResources = loadResources; + } + + /// + /// Initializes a new instance of the class that loads resources from the calling assembly. + /// + /// The prefix of resource file names to load. + /// a . + public static FluentLocalizationProvider FromEmbeddedResources(string prefix) => FromEmbeddedResources(Assembly.GetCallingAssembly(), prefix); + + /// + /// Initializes a new instance of the class that loads resources from the specified . + /// + /// The assembly to load resources from. + /// The prefix of resource file names to load. + /// a . + public static FluentLocalizationProvider FromEmbeddedResources(Assembly assembly, string prefix) + { + return new FluentLocalizationProvider(cultureInfo => + { + return assembly.GetManifestResourceNames() + .Where(n => n.StartsWith($"{prefix}.{cultureInfo.Name}.", StringComparison.Ordinal) && n.EndsWith(".ftl", StringComparison.Ordinal)) + .Select(n => (Resource) new StreamReader(assembly.GetManifestResourceStream(n)!)); + }); + } + + /// + /// Registers a for use with base game apis. + /// + /// The id of a Fluent message. + /// A . + public StringNames Register(string id) + { + var stringNames = CustomStringName.Create(); + _ids[stringNames] = id; + + return stringNames; + } + + /// + /// Registers a for use with base game apis with overriden format argument names. + /// + /// The id of a Fluent message. + /// An array of Fluent variable names in the same order as format arguments. + /// A . + public StringNames Register(string id, params string[] argumentNames) + { + var stringNames = Register(id); + _arguments[stringNames] = argumentNames; + + return stringNames; + } + + /// + public override int Priority => ReactorPriority.Normal; + + /// + public override void OnLanguageChanged(SupportedLangs newLanguage) + { + if (CurrentLanguage == newLanguage) return; + + _bundles.Clear(); + _bundles.Add(Load(newLanguage.ToCultureInfo())); + if (newLanguage != FallbackLanguage) _bundles.Add(Load(FallbackLanguage.ToCultureInfo())); + } + + private FluentBundle Load(CultureInfo culture) + { + return LinguiniBuilder.Builder() + .CultureInfo(culture) + .AddResources(_loadResources(culture)) + .SetUseIsolating(false) + .UncheckedBuild(); + } + + private string GetMsg(string id, IDictionary? args) + { + foreach (var bundle in _bundles) + { + if (bundle.TryGetMsg(id, null, args, out var errors, out var message)) + { + if (errors.Count > 0) + { + throw new LinguiniException(errors); + } + + return message; + } + } + + throw new KeyNotFoundException(); + } + + private static bool Is(Il2CppObjectBase o) + { + return IL2CPP.il2cpp_class_is_assignable_from(Il2CppClassPointerStore.NativeClassPtr, IL2CPP.il2cpp_object_get_class(o.Pointer)); + } + + private static bool TryUnbox(Il2CppObjectBase o, [NotNullWhen(true)] out T? result) where T : unmanaged + { + if (!Is(o)) + { + result = null; + return false; + } + + result = o.Unbox(); + return true; + } + + private static IFluentType ToFluentType(Object o) + { + if (Is(o)) return new FluentString(IL2CPP.Il2CppStringToManaged(o.Pointer)); + + if (TryUnbox(o, out var @byte)) return (FluentNumber) @byte; + if (TryUnbox(o, out var @sbyte)) return (FluentNumber) @sbyte; + if (TryUnbox(o, out var @short)) return (FluentNumber) @short; + if (TryUnbox(o, out var @int)) return (FluentNumber) @int; + if (TryUnbox(o, out var @long)) return (FluentNumber) @long; + if (TryUnbox(o, out var @ushort)) return (FluentNumber) @ushort; + if (TryUnbox(o, out var @uint)) return (FluentNumber) @uint; + if (TryUnbox(o, out var @ulong)) return (FluentNumber) @ulong; + if (TryUnbox(o, out var @float)) return (FluentNumber) @float; + if (TryUnbox(o, out var @double)) return (FluentNumber) @double; + + return new FluentString(o.ToString()); + } + + private static IFluentType ToFluentType(object o) + { + return o switch + { + string str => (FluentString) str, + byte num => (FluentNumber) num, + sbyte num => (FluentNumber) num, + short num => (FluentNumber) num, + ushort num => (FluentNumber) num, + int num => (FluentNumber) num, + uint num => (FluentNumber) num, + long num => (FluentNumber) num, + ulong num => (FluentNumber) num, + double num => (FluentNumber) num, + float num => (FluentNumber) num, + _ => (FluentString) o.ToString()!, + }; + } + + /// + /// Returns the localized text for the given fluent message . + /// + /// The id of the message. + /// The arguments used for formatting. + /// Text localized and formatted by fluent. + public string GetText(string id, params (string, object)[] args) + { + var fluentArgs = new Dictionary(args.Length); + foreach (var (key, value) in args) + { + fluentArgs[key] = ToFluentType(value); + } + + return GetMsg(id, fluentArgs); + } + + /// + public override bool TryGetTextFormatted(StringNames stringName, Il2CppReferenceArray parts, [NotNullWhen(true)] out string? result) + { + if (_ids.TryGetValue(stringName, out var id)) + { + var hasArgumentsOverride = _arguments.TryGetValue(stringName, out var arguments); + if (hasArgumentsOverride) + { + if (arguments!.Length != parts.Length) + throw new InvalidOperationException($"Arguments override for {id} has an invalid count of arguments"); + } + + var args = new Dictionary(parts.Length); + for (var i = 0; i < parts.Count; i++) + { + var part = parts[i]; + + if (hasArgumentsOverride) + { + args[arguments![i]] = ToFluentType(part); + } + else + { + // Fluent doesn't allow positional variables nor starting variable names with numbers so we have to use a,b,c,d... instead + if (i > 25) throw new InvalidOperationException("Too many format parts"); + var c = (char) ('a' + i); + args[c.ToString()] = ToFluentType(part); + } + } + + result = GetMsg(id, args); + return true; + } + + result = null; + return false; + } + + /// + public override bool TryGetText(StringNames stringName, [NotNullWhen(true)] out string? result) + { + if (_ids.TryGetValue(stringName, out var id)) + { + result = GetMsg(id, null); + return true; + } + + result = null; + return false; + } +} diff --git a/Reactor/Networking/Patches/HttpPatches.cs b/Reactor/Networking/Patches/HttpPatches.cs index a24bcec..4252e24 100644 --- a/Reactor/Networking/Patches/HttpPatches.cs +++ b/Reactor/Networking/Patches/HttpPatches.cs @@ -87,7 +87,7 @@ public static void Postfix(UnityWebRequest __instance, UnityWebRequestAsyncOpera { if (responseHeader == null) { - DisconnectPopup.Instance.ShowCustom("This region doesn't support modded handshake.\nThe lobbies shown may not be compatible with your current mods.\nFor more info see https://reactor.gg/handshake"); + DisconnectPopup.Instance.ShowCustom(Translate.Messages.VanillaServerWarning); } } @@ -126,7 +126,7 @@ public static void Postfix(GameStartManager __instance) size.x *= 2.5f; background.size = size; popup.TextAreaTMP.fontSizeMin = 2; - popup.Show("You can't make public lobbies on regions that don't support modded handshake.\nFor more info see https://reactor.gg/handshake"); + popup.Show(Translate.Messages.PublicLobbyWarning); })); if (AmongUsClient.Instance.AmHost && AmongUsClient.Instance.IsGamePublic) diff --git a/Reactor/Reactor.csproj b/Reactor/Reactor.csproj index f532132..89d44c7 100644 --- a/Reactor/Reactor.csproj +++ b/Reactor/Reactor.csproj @@ -1,6 +1,7 @@ latest + true Core mod and API for Among Us @@ -16,10 +17,14 @@ + + + + diff --git a/Reactor/ReactorPlugin.cs b/Reactor/ReactorPlugin.cs index a007ca9..4576045 100644 --- a/Reactor/ReactorPlugin.cs +++ b/Reactor/ReactorPlugin.cs @@ -41,6 +41,8 @@ public partial class ReactorPlugin : BasePlugin internal RegionInfoWatcher RegionInfoWatcher { get; } = new(); + internal FluentLocalizationProvider LocalizationProvider { get; } = FluentLocalizationProvider.FromEmbeddedResources("Reactor.Assets.Localization"); + /// public ReactorPlugin() { @@ -55,6 +57,7 @@ public ReactorPlugin() MethodRpcAttribute.Initialize(); LocalizationManager.Register(new HardCodedLocalizationProvider()); + LocalizationManager.Register(Translate.Provider = LocalizationProvider); } /// diff --git a/Reactor/packages.lock.json b/Reactor/packages.lock.json index 5271e2c..b1f3100 100644 --- a/Reactor/packages.lock.json +++ b/Reactor/packages.lock.json @@ -37,6 +37,16 @@ "Samboy063.Cpp2IL.Core": "2022.0.7.2" } }, + "Linguini.Bundle": { + "type": "Direct", + "requested": "[0.3.1, )", + "resolved": "0.3.1", + "contentHash": "k3D0IBSsS91T0G9rYACRYtAS27NDTGz4T3ygHhbhaFdA9i3cCfCpR39MqYEpoA1Ark3lzmvklHePmRCX5EQi1A==", + "dependencies": { + "Linguini.Shared": "0.3.0", + "Linguini.Syntax": "0.3.0" + } + }, "Microsoft.SourceLink.GitHub": { "type": "Direct", "requested": "[1.1.1, )", @@ -141,6 +151,19 @@ "resolved": "2.1.0", "contentHash": "YYpq7NM50bSSVUDjXyV/eiITk6syXqItPjKBOb3jEWS6RFsk0DNhpWMPW6b3hKDmArARuWDU6S2pVu1IeVrvIA==" }, + "Linguini.Shared": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "rEYA9jWU1Nc9iDuPKar+4IVXjFbGrcNyYZCeU6hiWQoS4nA2UecHu36Rp4bxzasvRGda4FB2JZqty75m4e26VQ==" + }, + "Linguini.Syntax": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "v8YV3xPFZfP+OSWjMd6yysp+o7cNnQXxq/slN8Yekoi+dFjsTNF0YiQWJIwg1j2FUbs6claJxY9R1FFlc4s6MA==", + "dependencies": { + "Linguini.Shared": "0.3.0" + } + }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", "resolved": "1.1.1", @@ -886,4 +909,4 @@ } } } -} +} \ No newline at end of file