Compare commits

...

9 Commits

Author SHA1 Message Date
Pieter-Jan Briers
6d41958853 Fix nullability of TryIndex<T>. 2021-02-23 22:25:48 +01:00
Pieter-Jan Briers
cecc4dfcf2 Improve SharedTransformSystem:
Do not fire events for deleted entities.
Optimize to remove allocations & LINQ.
2021-02-23 22:05:49 +01:00
Pieter-Jan Briers
4ac40f2e90 Make norot on sprites default for the time being.
To band aid the pulling issues.
2021-02-23 21:32:00 +01:00
Pieter-Jan Briers
3e12d44173 Bool/enum/entity handling for localization parameters. 2021-02-23 20:59:21 +01:00
DrSmugleaf
22affccf24 Add individual layer offset (#1583)
* Add individual layer offset

* Fix error message

* Bring back layer offsetting
2021-02-23 12:55:45 +01:00
Pieter-Jan Briers
028724c47b Localization improvements:
*Allow content to define localization functions.
* Add rldloc command to reload localizations.
* Doc comments
* Error handling
* Parallelize loading of localization files, since I can only assume we'll have a lot eventually.
* Type system stuff to allow content to pass custom data types into fluent.
* Code cleanup.
2021-02-23 11:35:54 +01:00
Pieter-Jan Briers
0114bff2fc Add IFormattable to sandbox whitelist. 2021-02-23 11:27:51 +01:00
Pieter-Jan Briers
4ddbd644eb Add helper method to set up logging inside unit tests. 2021-02-23 11:27:42 +01:00
Pieter-Jan Briers
f0366531ef Inject the csi directly into my master. 2021-02-23 01:39:33 +01:00
18 changed files with 820 additions and 92 deletions

View File

@@ -0,0 +1,20 @@
using Robust.Shared.Console;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
namespace Robust.Client.Console.Commands
{
internal sealed class ReloadLocalizationsCommand : IConsoleCommand
{
public string Command => "rldloc";
public string Description => "Reloads localization (client & server)";
public string Help => "Usage: rldloc";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
IoCManager.Resolve<ILocalizationManager>().ReloadLocalizations();
shell.RemoteExecuteCommand("sudo rldloc");
}
}
}

View File

@@ -1,5 +1,7 @@
#if CLIENT_SCRIPTING
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using JetBrains.Annotations;
using Microsoft.CodeAnalysis;
@@ -36,6 +38,8 @@ namespace Robust.Client.Console
private readonly ScriptGlobals _globals;
private ScriptState? _state;
private (string[] imports, string code)? _autoImportRepeatBuffer;
public ScriptConsoleClient()
{
Title = Loc.GetString("Robust C# Interactive (CLIENT)");
@@ -54,38 +58,56 @@ namespace Robust.Client.Console
var code = InputBar.Text;
InputBar.Clear();
// Remove > or . at the end of the output panel.
OutputPanel.RemoveEntry(^1);
_inputBuffer.AppendLine(code);
_linesEntered += 1;
var tree = SyntaxFactory.ParseSyntaxTree(SourceText.From(_inputBuffer.ToString()), ScriptInstanceShared.ParseOptions);
if (!SyntaxFactory.IsCompleteSubmission(tree))
if (_autoImportRepeatBuffer.HasValue && code == "y")
{
if (_linesEntered == 1)
var (imports, repeatCode) = _autoImportRepeatBuffer.Value;
var sb = new StringBuilder();
foreach (var import in imports)
{
OutputPanel.AddText($"> {code}");
sb.AppendFormat("using {0};\n", import);
}
else
{
OutputPanel.AddText($". {code}");
}
OutputPanel.AddText(".");
return;
sb.Append(repeatCode);
code = sb.ToString();
}
code = _inputBuffer.ToString().Trim();
// Remove echo of partial submission from the output panel.
for (var i = 1; i < _linesEntered; i++)
else
{
// Remove > or . at the end of the output panel.
OutputPanel.RemoveEntry(^1);
}
_inputBuffer.Clear();
_linesEntered = 0;
_inputBuffer.AppendLine(code);
_linesEntered += 1;
var tree = SyntaxFactory.ParseSyntaxTree(SourceText.From(_inputBuffer.ToString()),
ScriptInstanceShared.ParseOptions);
if (!SyntaxFactory.IsCompleteSubmission(tree))
{
if (_linesEntered == 1)
{
OutputPanel.AddText($"> {code}");
}
else
{
OutputPanel.AddText($". {code}");
}
OutputPanel.AddText(".");
return;
}
code = _inputBuffer.ToString().Trim();
// Remove echo of partial submission from the output panel.
for (var i = 1; i < _linesEntered; i++)
{
OutputPanel.RemoveEntry(^1);
}
_inputBuffer.Clear();
_linesEntered = 0;
}
Script newScript;
@@ -135,6 +157,8 @@ namespace Robust.Client.Console
OutputPanel.AddMessage(msg);
OutputPanel.AddText(">");
PromptAutoImports(e.Diagnostics, code);
return;
}
@@ -155,6 +179,17 @@ namespace Robust.Client.Console
OutputPanel.AddText(">");
}
private void PromptAutoImports(IEnumerable<Diagnostic> diags, string code)
{
if (!ScriptInstanceShared.CalcAutoImports(_reflectionManager, diags, out var found))
return;
OutputPanel.AddText($"Auto-import {string.Join(", ", found)} (enter 'y')?");
_autoImportRepeatBuffer = (found.ToArray(), code);
}
private sealed class ScriptGlobalsImpl : ScriptGlobals
{
private readonly ScriptConsoleClient _owner;

View File

@@ -318,7 +318,7 @@ namespace Robust.Client
logManager.GetSawmill("discord").Level = LogLevel.Warning;
logManager.GetSawmill("net.predict").Level = LogLevel.Info;
logManager.GetSawmill("szr").Level = LogLevel.Info;
logManager.GetSawmill("Loc").Level = LogLevel.Error;
logManager.GetSawmill("loc").Level = LogLevel.Error;
#if DEBUG_ONLY_FCE_INFO
#if DEBUG_ONLY_FCE_LOG

View File

@@ -945,6 +945,31 @@ namespace Robust.Client.GameObjects
LayerSetAutoAnimated(layer, autoAnimated);
}
public void LayerSetOffset(int layer, Vector2 layerOffset)
{
if (Layers.Count <= layer)
{
Logger.ErrorS(LogCategory,
"Layer with index '{0}' does not exist, cannot set offset! Trace:\n{1}",
layer, Environment.StackTrace);
return;
}
Layers[layer].SetOffset(layerOffset);
}
public void LayerSetOffset(object layerKey, Vector2 layerOffset)
{
if (!LayerMapTryGet(layerKey, out var layer))
{
Logger.ErrorS(LogCategory, "Layer with key '{0}' does not exist, cannot set offset! Trace:\n{1}",
layerKey, Environment.StackTrace);
return;
}
LayerSetOffset(layer, layerOffset);
}
/// <inheritdoc />
public RSI.StateId LayerGetState(int layer)
{
@@ -1057,7 +1082,7 @@ namespace Robust.Client.GameObjects
var layerColor = color * layer.Color;
var position = -(Vector2) texture.Size / (2f * EyeManager.PixelsPerMeter);
var position = -(Vector2) texture.Size / (2f * EyeManager.PixelsPerMeter) + layer.Offset;
var textureSize = texture.Size / (float) EyeManager.PixelsPerMeter;
var quad = Box2.FromDimensions(position, textureSize);
@@ -1140,7 +1165,7 @@ namespace Robust.Client.GameObjects
serializer.DataFieldCached(ref color, "color", Color.White);
serializer.DataFieldCached(ref _visible, "visible", true);
serializer.DataFieldCached(ref _directional, "directional", true); //TODO: Kill ME
serializer.DataFieldCached(ref _screenLock, "noRot", false);
serializer.DataFieldCached(ref _screenLock, "noRot", true);
serializer.DataFieldCached(ref _enableOverrideDirection, "enableOverrideDir", false);
serializer.DataFieldCached(ref _overrideDirection, "overrideDir", Direction.East);
@@ -1620,16 +1645,25 @@ namespace Robust.Client.GameObjects
[ViewVariables(VVAccess.ReadWrite)]
public Vector2 Scale { get; set; } = Vector2.One;
[ViewVariables(VVAccess.ReadWrite)]
public Angle Rotation { get; set; }
[ViewVariables(VVAccess.ReadWrite)]
public bool Visible = true;
[ViewVariables(VVAccess.ReadWrite)]
public Color Color { get; set; } = Color.White;
[ViewVariables(VVAccess.ReadWrite)]
public bool AutoAnimated = true;
[ViewVariables(VVAccess.ReadWrite)]
public Vector2 Offset { get; set; }
[ViewVariables]
public DirectionOffset DirOffset { get; set; }
[ViewVariables]
public RSI? ActualRsi => RSI ?? _parent.BaseRSI;
@@ -1850,6 +1884,11 @@ namespace Robust.Client.GameObjects
_parent.UpdateIsInert();
}
public void SetOffset(Vector2 offset)
{
Offset = offset;
}
}
void IAnimationProperties.SetAnimatableProperty(string name, object value)

View File

@@ -0,0 +1,18 @@
using Robust.Shared.Console;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
namespace Robust.Server.Console.Commands
{
internal sealed class ReloadLocalizationsCommand : IConsoleCommand
{
public string Command => "rldloc";
public string Description => "Reloads localization (client & server)";
public string Help => "Usage: rldloc";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
IoCManager.Resolve<ILocalizationManager>().ReloadLocalizations();
}
}
}

View File

@@ -115,7 +115,7 @@ namespace Robust.Server
mgr.RootSawmill.AddHandler(handler);
mgr.GetSawmill("res.typecheck").Level = LogLevel.Info;
mgr.GetSawmill("go.sys").Level = LogLevel.Info;
mgr.GetSawmill("Loc").Level = LogLevel.Error;
mgr.GetSawmill("loc").Level = LogLevel.Error;
// mgr.GetSawmill("szr").Level = LogLevel.Info;
#if DEBUG_ONLY_FCE_INFO

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using JetBrains.Annotations;
using Microsoft.CodeAnalysis;
@@ -76,7 +77,7 @@ namespace Robust.Server.Scripting
return;
}
if (!_conGroupController.CanViewVar(session))
if (!_conGroupController.CanScript(session))
{
Logger.WarningS("script", "Client {0} tried to access Scripting without permissions.", session);
_netManager.ServerSendMessage(reply, message.MsgChannel);
@@ -108,7 +109,7 @@ namespace Robust.Server.Scripting
return;
}
if (!_conGroupController.CanViewVar(session))
if (!_conGroupController.CanScript(session))
{
Logger.WarningS("script", "Client {0} tried to access Scripting without permissions.", session);
return;
@@ -125,23 +126,40 @@ namespace Robust.Server.Scripting
var code = message.Code;
instance.InputBuffer.AppendLine(code);
var tree = SyntaxFactory.ParseSyntaxTree(SourceText.From(instance.InputBuffer.ToString()),
ScriptInstanceShared.ParseOptions);
if (!SyntaxFactory.IsCompleteSubmission(tree))
if (code == "y" && instance.AutoImportRepeatBuffer.HasValue)
{
replyMessage.WasComplete = false;
_netManager.ServerSendMessage(replyMessage, message.MsgChannel);
return;
var (imports, repeatCode) = instance.AutoImportRepeatBuffer.Value;
var sb = new StringBuilder();
foreach (var import in imports)
{
sb.AppendFormat("using {0};\n", import);
}
sb.Append(repeatCode);
code = sb.ToString();
replyMessage.WasComplete = true;
}
else
{
instance.InputBuffer.AppendLine(code);
replyMessage.WasComplete = true;
var tree = SyntaxFactory.ParseSyntaxTree(SourceText.From(instance.InputBuffer.ToString()),
ScriptInstanceShared.ParseOptions);
code = instance.InputBuffer.ToString().Trim();
if (!SyntaxFactory.IsCompleteSubmission(tree))
{
replyMessage.WasComplete = false;
_netManager.ServerSendMessage(replyMessage, message.MsgChannel);
return;
}
instance.InputBuffer.Clear();
replyMessage.WasComplete = true;
code = instance.InputBuffer.ToString().Trim();
instance.InputBuffer.Clear();
}
Script newScript;
@@ -188,6 +206,8 @@ namespace Robust.Server.Scripting
msg.AddText("\n");
}
PromptAutoImports(e.Diagnostics, code, msg, instance);
replyMessage.Response = msg;
_netManager.ServerSendMessage(replyMessage, message.MsgChannel);
return;
@@ -217,6 +237,21 @@ namespace Robust.Server.Scripting
_netManager.ServerSendMessage(replyMessage, message.MsgChannel);
}
private void PromptAutoImports(
IEnumerable<Diagnostic> diags,
string code,
FormattedMessage output,
ScriptInstance instance)
{
if (!ScriptInstanceShared.CalcAutoImports(_reflectionManager, diags, out var found))
return;
output.AddText($"Auto-import {string.Join(", ", found)} (enter 'y')?");
instance.AutoImportRepeatBuffer = (found.ToArray(), code);
}
private sealed class ScriptInstance
{
public Workspace HighlightWorkspace { get; } = new AdhocWorkspace();
@@ -227,6 +262,8 @@ namespace Robust.Server.Scripting
public ScriptGlobals Globals { get; }
public ScriptState? State { get; set; }
public (string[] imports, string code)? AutoImportRepeatBuffer;
public ScriptInstance()
{
Globals = new ScriptGlobalsImpl(this);

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading.Tasks;
using Lidgren.Network;
using Microsoft.CodeAnalysis;
@@ -23,6 +25,7 @@ namespace Robust.Shared.Scripting
new(kind: SourceCodeKind.Script, languageVersion: LanguageVersion.Latest);
private static readonly Func<Script, bool> _hasReturnValue;
private static readonly Func<Diagnostic, IReadOnlyList<object?>?> _getDiagnosticArguments;
private static readonly string[] _defaultImports =
{
@@ -47,10 +50,23 @@ namespace Robust.Shared.Scripting
// Fallback path in case they remove that.
// The method literally has a // TODO: remove
_hasReturnValue = _ => true;
return;
}
else
{
_hasReturnValue = (Func<Script, bool>) Delegate.CreateDelegate(typeof(Func<Script, bool>), method);
}
_hasReturnValue = (Func<Script, bool>) Delegate.CreateDelegate(typeof(Func<Script, bool>), method);
// Also internal and we need it.
var prop = typeof(Diagnostic).GetProperty("Arguments", BindingFlags.Instance | BindingFlags.NonPublic);
if (prop == null)
{
_getDiagnosticArguments = _ => null;
}
else
{
var moment = prop.GetMethod!;
_getDiagnosticArguments = moment.CreateDelegate<Func<Diagnostic, IReadOnlyList<object?>?>>();
}
// Run this async so that Roslyn can "warm up" in another thread while you're typing in your first line,
// so the hang when you hit enter is less bad.
@@ -79,6 +95,11 @@ namespace Robust.Shared.Scripting
return _hasReturnValue(script);
}
public static IReadOnlyList<object?>? GetDiagnosticArgs(Diagnostic diag)
{
return _getDiagnosticArguments(diag);
}
public static void AddWithSyntaxHighlighting(Script script, FormattedMessage msg, string code,
Workspace workspace)
{
@@ -139,6 +160,60 @@ namespace Robust.Shared.Scripting
return list;
}
private static IEnumerable<Assembly> GetAutoImportAssemblies(IReflectionManager refl)
{
return GetDefaultReferences(refl).Union(
AssemblyLoadContext.Default.Assemblies.Where(c => c.GetName().Name!.StartsWith("System."))
);
}
public static bool CalcAutoImports(
IReflectionManager refl,
IEnumerable<Diagnostic> diags,
[NotNullWhen(true)] out HashSet<string>? found)
{
var missing = new List<string>();
foreach (var diag in diags.Where(c => c.Id == "CS0103" || c.Id == "CS0246"))
{
var args = GetDiagnosticArgs(diag);
if (args == null)
{
found = null;
return false;
}
missing.Add((string) args[0]!);
}
if (missing.Count == 0)
{
found = null;
return false;
}
found = new HashSet<string>();
var assemblies = ScriptInstanceShared.GetAutoImportAssemblies(refl).ToArray();
foreach (var m in missing)
{
foreach (var assembly in assemblies)
{
foreach (var type in assembly.DefinedTypes)
{
if (type.IsPublic && type.Name == m)
{
found.Add(type.Namespace!);
goto nextMissing;
}
}
}
nextMissing: ;
}
return true;
}
public static ScriptOptions GetScriptOptions(IReflectionManager reflectionManager)
{
return ScriptOptions.Default

View File

@@ -864,6 +864,7 @@ Types:
IDisposable: { All: True }
IEquatable`1: { }
IFormatProvider: { All: True }
IFormattable: { All: True }
IndexOutOfRangeException: { All: True }
Int16: { All: True }
Int32: { All: True }

View File

@@ -1,32 +1,39 @@
using System.Collections.Generic;
using System.Linq;
namespace Robust.Shared.GameObjects
{
internal class SharedTransformSystem : EntitySystem
{
private readonly List<MoveEvent> _deferredMoveEvents = new();
private readonly Queue<MoveEvent> _gridMoves = new();
private readonly Queue<MoveEvent> _otherMoves = new();
public void DeferMoveEvent(MoveEvent moveEvent)
{
_deferredMoveEvents.Add(moveEvent);
if (moveEvent.Sender.HasComponent<IMapGridComponent>())
_gridMoves.Enqueue(moveEvent);
else
_otherMoves.Enqueue(moveEvent);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var events = _deferredMoveEvents
.OrderBy(e => e.Sender.HasComponent<IMapGridComponent>())
.ToArray();
// Process grid moves first.
Process(_gridMoves);
Process(_otherMoves);
foreach (var ev in events)
void Process(Queue<MoveEvent> queue)
{
ev.Sender.EntityManager.EventBus.RaiseEvent(EventSource.Local, ev);
ev.Handled = true;
}
while (queue.TryDequeue(out var ev))
{
if (ev.Sender.Deleted)
continue;
_deferredMoveEvents.RemoveAll(e => e.Handled);
RaiseLocalEvent(ev);
ev.Handled = true;
}
}
}
}
}

View File

@@ -31,6 +31,13 @@ namespace Robust.Shared.Localization
/// </returns>
string GetString(string messageId);
/// <summary>
/// Try- version of <see cref="GetString(string)"/>
/// </summary>
/// <remarks>
/// Does not log a warning if the message does not exist.
/// Does however log errors if any occur while formatting.
/// </remarks>
bool TryGetString(string messageId, [NotNullWhen(true)] out string? value);
/// <summary>
@@ -38,6 +45,13 @@ namespace Robust.Shared.Localization
/// </summary>
string GetString(string messageId, params (string, object)[] args);
/// <summary>
/// Try- version of <see cref="GetString(string, ValueTuple{string, object}[])"/>
/// </summary>
/// <remarks>
/// Does not log a warning if the message does not exist.
/// Does however log errors if any occur while formatting.
/// </remarks>
bool TryGetString(string messageId, [NotNullWhen(true)] out string? value, params (string, object)[] args);
/// <summary>
@@ -51,10 +65,27 @@ namespace Robust.Shared.Localization
/// </summary>
/// <param name="resourceManager"></param>
/// <param name="culture"></param>
void LoadCulture(IResourceManager resourceManager, CultureInfo culture);
[Obsolete("Use LoadCulture without IResourceManager overload instead.")]
void LoadCulture(IResourceManager resourceManager, CultureInfo culture) => LoadCulture(culture);
/// <summary>
/// Load data for a culture.
/// </summary>
/// <param name="culture"></param>
void LoadCulture(CultureInfo culture);
/// <summary>
/// Immediately reload ALL localizations from resources.
/// </summary>
void ReloadLocalizations();
/// <summary>
/// Add a function that can be called from Fluent localizations.
/// </summary>
/// <param name="culture">The culture to add the function instance for.</param>
/// <param name="name">The name of the function.</param>
/// <param name="function">The function itself.</param>
void AddFunction(CultureInfo culture, string name, LocFunction function);
/// <summary>
/// Remnants of the old Localization system.

View File

@@ -61,8 +61,8 @@ namespace Robust.Shared.Localization
/// Load data for a culture.
/// </summary>
/// <param name="resourceManager"></param>
/// <param name="macroFactory"></param>
/// <param name="culture"></param>
[Obsolete("Use ILocalizationManager directly for setup methods.")]
public static void LoadCulture(IResourceManager resourceManager, CultureInfo culture)
{
LocalizationManager.LoadCulture(resourceManager, culture);

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Fluent.Net;
using JetBrains.Annotations;
using Robust.Shared.GameObjects;
namespace Robust.Shared.Localization
{
// Basically Fluent.Net is based on an ancient pre-1.0 version of fluent.js.
// Said version of fluent.js was also a complete garbage fire implementation wise.
// So Fluent.Net is garbage implementation wise.
// ...yay
// (the current implementation of fluent.js is written in TS and actually sane)
//
// Because of this, we can't expose it to content, so we have to wrap everything related to functions.
// This basically mimics the modern typescript fluent.js API. Somewhat.
/// <summary>
/// Function signature runnable by localizations.
/// </summary>
/// <param name="args">Contains arguments and options passed to the function by the calling localization.</param>
public delegate ILocValue LocFunction(LocArgs args);
[PublicAPI]
public readonly struct LocContext
{
public CultureInfo Culture => Context.Culture;
internal readonly MessageContext Context;
internal LocContext(MessageContext ctx)
{
Context = ctx;
}
}
/// <summary>
/// Arguments and options passed to a localization function.
/// </summary>
[PublicAPI]
public readonly struct LocArgs
{
public LocArgs(IReadOnlyList<ILocValue> args, IReadOnlyDictionary<string, ILocValue> options)
{
Args = args;
Options = options;
}
/// <summary>
/// Positional arguments passed to the function, in order.
/// </summary>
public IReadOnlyList<ILocValue> Args { get; }
/// <summary>
/// Key-value options passed to the function.
/// </summary>
public IReadOnlyDictionary<string, ILocValue> Options { get; }
}
/// <summary>
/// A value passed around in the localization system.
/// </summary>
/// <seealso cref="LocValue{T}"/>
public interface ILocValue
{
/// <summary>
/// Format this value to a string.
/// </summary>
/// <remarks>
/// Used when this value is interpolated directly in localizations.
/// </remarks>
/// <param name="ctx">Context containing data like culture used.</param>
/// <returns>The formatted string.</returns>
string Format(LocContext ctx);
/// <summary>
/// Boxed value stored by this instance.
/// </summary>
object? Value { get; }
// Matches API doesn't work because Fluent.Net is crap.
// RIP.
/*
/// <summary>
/// Checks if this value matches a string in a select expression.
/// </summary>
bool Matches(LocContext ctx, string matchValue)
{
return false;
}
*/
}
/// <summary>
/// Default implementation of a localization value.
/// </summary>
/// <remarks>
/// The idea is that inheritors could add extra data like formatting parameters
/// and then use those by overriding <see cref="Format"/>.
/// </remarks>
/// <typeparam name="T">The type of value stored.</typeparam>
[PublicAPI]
public abstract record LocValue<T> : ILocValue
{
/// <summary>
/// The stored value.
/// </summary>
public T Value { get; init; }
object? ILocValue.Value => Value;
protected LocValue(T val)
{
Value = val;
}
public abstract string Format(LocContext ctx);
/*
public virtual bool Matches(LocContext ctx, string matchValue)
{
return false;
}
*/
}
public sealed record LocValueNumber(double Value) : LocValue<double>(Value)
{
public override string Format(LocContext ctx)
{
return Value.ToString(ctx.Culture);
}
}
public sealed record LocValueDateTime(DateTime Value) : LocValue<DateTime>(Value)
{
public override string Format(LocContext ctx)
{
return Value.ToString(ctx.Culture);
}
}
public sealed record LocValueString(string Value) : LocValue<string>(Value)
{
public override string Format(LocContext ctx)
{
return Value;
}
}
/// <summary>
/// Stores an "invalid" string value. Produced by e.g. unresolved variable references.
/// </summary>
public sealed record LocValueNone(string Value) : LocValue<string>(Value)
{
public override string Format(LocContext ctx)
{
return Value;
}
}
public sealed record LocValueEntity(IEntity Value) : LocValue<IEntity>(Value)
{
public override string Format(LocContext ctx)
{
return Value.Name;
}
}
// Matching on these doesn't work so just passing these as string for now.
/*public sealed record LocValueBool(bool Value) : LocValue<bool>(Value)
{
public override string Format(LocContext ctx)
{
return Value.ToString(ctx.Culture);
}
/*
public override bool Matches(LocContext ctx, string matchValue)
{
var word = Value ? "true" : "false";
return word.Equals(matchValue, StringComparison.InvariantCultureIgnoreCase);
}
#1#
}
public sealed record LocValueEnum(Enum Value) : LocValue<Enum>(Value)
{
public override string Format(LocContext ctx)
{
return Value.ToString().ToLowerInvariant();
}
/*public override bool Matches(LocContext ctx, string matchValue)
{
return matchValue.Equals(Value.ToString(), StringComparison.InvariantCultureIgnoreCase);
}#1#
}*/
}

View File

@@ -3,22 +3,34 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using Fluent.Net;
using Fluent.Net.RuntimeAst;
using JetBrains.Annotations;
using Robust.Shared.ContentPack;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Serialization;
using Robust.Shared.Utility;
namespace Robust.Shared.Localization
{
internal sealed class LocalizationManager : ILocalizationManagerInternal
internal sealed class LocalizationManager : ILocalizationManagerInternal, IPostInjectInit
{
[Dependency] private readonly IResourceManager _res = default!;
[Dependency] private readonly ILogManager _log = default!;
private ISawmill _logSawmill = default!;
private readonly Dictionary<CultureInfo, MessageContext> _contexts = new();
private CultureInfo? _defaultCulture;
void IPostInjectInit.PostInject()
{
_logSawmill = _log.GetSawmill("loc");
}
public string GetString(string messageId)
{
if (_defaultCulture == null)
@@ -26,7 +38,7 @@ namespace Robust.Shared.Localization
if (!TryGetString(messageId, out var msg))
{
Logger.WarningS("Loc", $"Unknown messageId ({_defaultCulture.IetfLanguageTag}): {messageId}");
_logSawmill.Warning("Unknown messageId ({culture}): {messageId}", _defaultCulture.Name, messageId);
msg = messageId;
}
@@ -41,8 +53,7 @@ namespace Robust.Shared.Localization
return false;
}
value = context.Format(node, null, null);
return true;
return DoFormat(messageId, out value, context, node);
}
public string GetString(string messageId, params (string, object)[] args0)
@@ -52,14 +63,15 @@ namespace Robust.Shared.Localization
if (!TryGetString(messageId, out var msg, args0))
{
Logger.WarningS("Loc", $"Unknown messageId ({_defaultCulture.IetfLanguageTag}): {messageId}");
_logSawmill.Warning("Unknown messageId ({culture}): {messageId}", _defaultCulture.Name, messageId);
msg = messageId;
}
return msg;
}
public bool TryGetString(string messageId, [NotNullWhen(true)] out string? value, params (string, object)[] args0)
public bool TryGetString(string messageId, [NotNullWhen(true)] out string? value,
params (string, object)[] args0)
{
if (!TryGetNode(messageId, out var context, out var node))
{
@@ -70,11 +82,20 @@ namespace Robust.Shared.Localization
var args = new Dictionary<string, object>();
foreach (var (k, v) in args0)
{
args.Add(k, v);
var val = v switch
{
IEntity entity => new LocValueEntity(entity),
bool or Enum => v.ToString()!.ToLowerInvariant(),
_ => v,
};
if (val is ILocValue locVal)
val = new FluentLocWrapperType(locVal);
args.Add(k, val);
}
value = context.Format(node, args, null);
return false;
return DoFormat(messageId, out value, context, node, args);
}
private bool TryGetNode(
@@ -109,7 +130,7 @@ namespace Robust.Shared.Localization
if (attribName != null)
{
if (!message.Attributes.TryGetValue(attribName, out var attrib))
if (message.Attributes == null || !message.Attributes.TryGetValue(attribName, out var attrib))
{
node = null;
return false;
@@ -125,6 +146,105 @@ namespace Robust.Shared.Localization
return true;
}
public void ReloadLocalizations()
{
foreach (var (culture, context) in _contexts.ToArray())
{
// Fluent.Net doesn't allow us to remove messages so...
var newContext = new MessageContext(
culture.Name,
new MessageContextOptions
{
UseIsolating = false,
Functions = context.Functions
}
);
_contexts[culture] = newContext;
_loadData(_res, culture, newContext);
}
}
public void AddFunction(CultureInfo culture, string name, LocFunction function)
{
var context = _contexts[culture];
context.Functions.Add(name, (args, options) => CallFunction(function, args, options));
}
private FluentType CallFunction(
LocFunction function,
IList<object> fluentArgs, IDictionary<string, object> fluentOptions)
{
var args = new ILocValue[fluentArgs.Count];
for (var i = 0; i < args.Length; i++)
{
args[i] = ValFromFluent(fluentArgs[i]);
}
var options = new Dictionary<string, ILocValue>(fluentOptions.Count);
foreach (var (k, v) in fluentOptions)
{
options.Add(k, ValFromFluent(v));
}
var argStruct = new LocArgs(args, options);
var ret = function(argStruct);
return ValToFluent(ret);
}
private static ILocValue ValFromFluent(object arg)
{
return arg switch
{
FluentNone none => new LocValueNone(none.Value),
FluentNumber number => new LocValueNumber(double.Parse(number.Value)),
FluentString str => new LocValueString(str.Value),
FluentDateTime dateTime =>
new LocValueDateTime(DateTime.Parse(dateTime.Value, null, DateTimeStyles.RoundtripKind)),
FluentLocWrapperType wrap => wrap.WrappedValue,
_ => throw new ArgumentOutOfRangeException(nameof(arg))
};
}
private static FluentType ValToFluent(ILocValue arg)
{
return arg switch
{
LocValueNone =>
throw new NotSupportedException("Cannot currently return LocValueNone from loc functions."),
LocValueNumber number => new FluentNumber(number.Value.ToString("R")),
LocValueString str => new FluentString(str.Value),
LocValueDateTime dateTime => new FluentDateTime(dateTime.Value),
_ => new FluentLocWrapperType(arg)
};
}
private bool DoFormat(string messageId, out string? value, MessageContext context, Node node, IDictionary<string, object>? args = null)
{
var errs = new List<FluentError>();
try
{
value = context.Format(node, args, errs);
}
catch (Exception e)
{
_logSawmill.Error("{culture}/{messageId}: {exception}", _defaultCulture!.Name, messageId, e);
value = null;
return false;
}
foreach (var err in errs)
{
_logSawmill.Error("{culture}/{messageId}: {error}", _defaultCulture!.Name, messageId, err);
}
return true;
}
/// <summary>
/// Remnants of the old Localization system.
/// It exists to prevent source errors and allow existing game text to *mostly* work
@@ -157,16 +277,23 @@ namespace Robust.Shared.Localization
}
}
public void LoadCulture(IResourceManager resourceManager, CultureInfo culture)
public void LoadCulture(CultureInfo culture)
{
var context = new MessageContext(
culture.Name,
new MessageContextOptions {UseIsolating = false}
new MessageContextOptions
{
UseIsolating = false,
// Have to pass empty dict here or else Fluent.Net will fuck up
// and share the same dict between multiple message contexts.
// Yes, you read that right.
Functions = new Dictionary<string, Resolver.ExternalFunction>(),
}
);
_contexts.Add(culture, context);
_loadData(resourceManager, culture, context);
_loadData(_res, culture, context);
if (DefaultCulture == null)
{
DefaultCulture = culture;
@@ -197,31 +324,53 @@ namespace Robust.Shared.Localization
*/
}
private static void _loadData(IResourceManager resourceManager, CultureInfo culture, MessageContext context)
private void _loadData(IResourceManager resourceManager, CultureInfo culture, MessageContext context)
{
// Load data from .ftl files.
// Data is loaded from /Locale/<language-code>/*
var root = new ResourcePath($"/Locale/{culture.IetfLanguageTag}/");
var root = new ResourcePath($"/Locale/{culture.Name}/");
foreach (var file in resourceManager.ContentFindFiles(root))
var files = resourceManager.ContentFindFiles(root).ToArray();
var resources = files.AsParallel().Select(path =>
{
var ftlFile = root / file;
_loadFromFile(resourceManager, ftlFile, context);
using var fileStream = resourceManager.ContentFileRead(path);
using var reader = new StreamReader(fileStream, EncodingHelpers.UTF8);
var resource = FluentResource.FromReader(reader);
return (path, resource);
});
foreach (var (path, resource) in resources)
{
var errors = context.AddResource(resource);
foreach (var error in errors)
{
_logSawmill.Warning("{path}: {exception}", path, error.Message);
}
}
}
private static void _loadFromFile(IResourceManager resourceManager, ResourcePath filePath,
MessageContext context)
private sealed class FluentLocWrapperType : FluentType
{
using (var fileStream = resourceManager.ContentFileRead(filePath))
using (var reader = new StreamReader(fileStream, EncodingHelpers.UTF8))
public readonly ILocValue WrappedValue;
public FluentLocWrapperType(ILocValue wrappedValue)
{
var errors = context.AddMessages(reader);
foreach (var error in errors)
{
Logger.WarningS("Loc", error.Message);
}
WrappedValue = wrappedValue;
}
public override string Format(MessageContext ctx)
{
return WrappedValue.Format(new LocContext(ctx));
}
public override bool Match(MessageContext ctx, object obj)
{
return false;
/*var strVal = obj is IFluentType ft ? ft.Value : obj.ToString() ?? "";
return WrappedValue.Matches(new LocContext(ctx), strVal);*/
}
}
}

View File

@@ -60,7 +60,7 @@ namespace Robust.Shared.Prototypes
/// </exception>
IPrototype Index(Type type, string id);
bool HasIndex<T>(string id) where T : IPrototype;
bool TryIndex<T>(string id, out T prototype) where T : IPrototype;
bool TryIndex<T>(string id, [NotNullWhen(true)] out T? prototype) where T : IPrototype;
/// <summary>
/// Load prototypes from files in a directory, recursively.
@@ -464,7 +464,7 @@ namespace Robust.Shared.Prototypes
return index.ContainsKey(id);
}
public bool TryIndex<T>(string id, [MaybeNullWhen(false)] out T prototype) where T : IPrototype
public bool TryIndex<T>(string id, [NotNullWhen(true)] out T? prototype) where T : IPrototype
{
if (!prototypes.TryGetValue(typeof(T), out var index))
{

View File

@@ -0,0 +1,26 @@
using Robust.Shared.IoC;
using Robust.Shared.Log;
namespace Robust.UnitTesting
{
/// <summary>
/// Helpers for setting up IoC in tests.
/// </summary>
public static class IocExt
{
/// <summary>
/// Registers the log manager with test log handler to a dependency collection.
/// </summary>
/// <param name="deps">Dependency collection to register into.</param>
/// <param name="prefix">Prefix for the test log output.</param>
public static void RegisterLogs(this IDependencyCollection deps, string? prefix = null)
{
deps.Register<ILogManager, LogManager>(() =>
{
var log = new LogManager();
log.RootSawmill.AddHandler(new TestLogHandler(prefix));
return log;
});
}
}
}

View File

@@ -0,0 +1,85 @@
using System.Globalization;
using NUnit.Framework;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Maths;
namespace Robust.UnitTesting.Shared.Localization
{
[TestFixture]
internal sealed class LocalizationTests
{
private const string FluentCode = @"
foo = { BAR($baz) }
enum-match = { $enum ->
[foo] A
*[bar] B
}
";
private (ILocalizationManager, CultureInfo) BuildLocalizationManager()
{
var ioc = new DependencyCollection();
ioc.Register<ILocalizationManager, LocalizationManager>();
ioc.Register<IResourceManager, ResourceManager>();
ioc.Register<IResourceManagerInternal, ResourceManager>();
ioc.Register<IConfigurationManager, ConfigurationManager>();
ioc.RegisterLogs();
ioc.BuildGraph();
var res = ioc.Resolve<IResourceManagerInternal>();
res.MountString("/Locale/en-US/a.ftl", FluentCode);
var loc = ioc.Resolve<ILocalizationManager>();
var culture = new CultureInfo("en-US", false);
loc.LoadCulture(culture);
return (loc, culture);
}
[Test]
public void TestCustomTypes()
{
var (loc, culture) = BuildLocalizationManager();
loc.AddFunction(culture, "BAR", Function);
var ret = loc.GetString("foo", ("baz", new LocValueVector2((-7, 5))));
Assert.That(ret, Is.EqualTo("5"));
}
[Test]
public void TestEnumSelect()
{
var (loc, _) = BuildLocalizationManager();
Assert.That(loc.GetString("enum-match", ("enum", TestEnum.Foo)), Is.EqualTo("A"));
Assert.That(loc.GetString("enum-match", ("enum", TestEnum.Bar)), Is.EqualTo("B"));
Assert.That(loc.GetString("enum-match", ("enum", TestEnum.Baz)), Is.EqualTo("B"));
}
private static ILocValue Function(LocArgs args)
{
return new LocValueNumber(((LocValueVector2) args.Args[0]).Value.Y);
}
private sealed record LocValueVector2(Vector2 Value) : LocValue<Vector2>(Value)
{
public override string Format(LocContext ctx)
{
return Value.ToString();
}
}
private enum TestEnum
{
Foo,
Bar,
Baz
}
}
}

View File

@@ -10,18 +10,17 @@ namespace Robust.UnitTesting
public sealed class TestLogHandler : ILogHandler, IDisposable
{
private readonly string _prefix;
private readonly string? _prefix;
private readonly TextWriter _writer;
private readonly Stopwatch _sw = Stopwatch.StartNew();
public TestLogHandler(string prefix)
public TestLogHandler(string? prefix = null)
{
_prefix = prefix;
_writer = TestContext.Out;
_writer.WriteLine($"{_prefix}: Started {DateTime.Now:o}");
_writer.WriteLine($"{GetPrefix()}Started {DateTime.Now:o}");
}
public void Dispose()
@@ -34,9 +33,13 @@ namespace Robust.UnitTesting
var name = LogMessage.LogLevelToName(message.Level.ToRobust());
var seconds = _sw.ElapsedMilliseconds/1000d;
var rendered = message.RenderMessage();
_writer.WriteLine($"{_prefix}: {seconds:F3}s [{name}] {sawmillName}: {rendered}");
_writer.WriteLine($"{GetPrefix()}{seconds:F3}s [{name}] {sawmillName}: {rendered}");
}
private string GetPrefix()
{
return _prefix != null ? $"{_prefix}: " : "";
}
}
}