Language Refactor 3 (#937)

# Description
This significantly improves the quality of the language system by fixing
the mistakes I've made almost a year ago while developing it.

Mainly, this throws away the old half-broken way of networking in favor
of the component state system provided by RT. Language speaker comp is
now shared with SendOnlyToOwner = true, and its state is handled
manually.

In addition to that, this brings the following changes:
- UniversalLanguageSpeaker and LanguageKnowledge are now server-side
- DetermineLanguagesEvent and LanguagesUpdateEvent are now shared (so
that future systems can be built in shared, if needed)
- Everything now uses the ProtoId<LanguagePrototype> type instead of raw
strings (god, I hated those so much)
- The server-side language system now accepts Entity<T?> arguments
instead of EntityUid + T
- UniversalLanguageSpeaker is now based on DetermineEntityLanguagesEvent
and gets an Enabled field, which allows to turn it off. This may have
some use in the future.
- Some minor cleanup

<!--
TODO MEDIA

<details><summary><h1>Media</h1></summary>
<p>

![Example Media Embed](https://example.com/thisimageisntreal.png)

</p>
</details>

-->

# Changelog
No cl

---------

Co-authored-by: VMSolidus <evilexecutive@gmail.com>
This commit is contained in:
Mnemotechnican
2024-10-09 18:49:07 +03:00
committed by Remuchi
parent 4d222b98b3
commit 82cbea2aef
24 changed files with 248 additions and 326 deletions

View File

@@ -1,48 +1,51 @@
using Content.Client.Language.Systems;
using Content.Shared.Language;
using Content.Shared.Language.Components;
using Content.Shared.Language.Events;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
namespace Content.Client.Language;
[GenerateTypedNameReferences]
public sealed partial class LanguageMenuWindow : DefaultWindow, IEntityEventSubscriber
public sealed partial class LanguageMenuWindow : DefaultWindow
{
private readonly LanguageSystem _clientLanguageSystem;
private readonly List<EntryState> _entries = new();
public LanguageMenuWindow()
{
RobustXamlLoader.Load(this);
_clientLanguageSystem = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<LanguageSystem>();
_clientLanguageSystem.OnLanguagesChanged += OnUpdateState;
_clientLanguageSystem.OnLanguagesChanged += UpdateState;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_clientLanguageSystem.OnLanguagesChanged -= OnUpdateState;
if (disposing)
_clientLanguageSystem.OnLanguagesChanged -= UpdateState;
}
protected override void Opened()
{
// Refresh the window when it gets opened.
// This actually causes two refreshes: one immediately, and one after the server sends a state message.
UpdateState(_clientLanguageSystem.CurrentLanguage, _clientLanguageSystem.SpokenLanguages);
_clientLanguageSystem.RequestStateUpdate();
UpdateState();
}
private void OnUpdateState(object? sender, LanguagesUpdatedMessage args)
private void UpdateState()
{
UpdateState(args.CurrentLanguage, args.Spoken);
var languageSpeaker = _clientLanguageSystem.GetLocalSpeaker();
if (languageSpeaker == null)
return;
UpdateState(languageSpeaker.CurrentLanguage, languageSpeaker.SpokenLanguages);
}
public void UpdateState(string currentLanguage, List<string> spokenLanguages)
public void UpdateState(ProtoId<LanguagePrototype> currentLanguage, List<ProtoId<LanguagePrototype>> spokenLanguages)
{
var langName = Loc.GetString($"language-{currentLanguage}-name");
CurrentLanguageLabel.Text = Loc.GetString("language-menu-current-language", ("language", langName));
@@ -58,15 +61,15 @@ public sealed partial class LanguageMenuWindow : DefaultWindow, IEntityEventSubs
// Disable the button for the currently chosen language
foreach (var entry in _entries)
{
if (entry.button != null)
entry.button.Disabled = entry.language == currentLanguage;
if (entry.Button != null)
entry.Button.Disabled = entry.Language == currentLanguage;
}
}
private void AddLanguageEntry(string language)
private void AddLanguageEntry(ProtoId<LanguagePrototype> language)
{
var proto = _clientLanguageSystem.GetLanguagePrototype(language);
var state = new EntryState { language = language };
var state = new EntryState { Language = language };
var container = new BoxContainer { Orientation = BoxContainer.LayoutOrientation.Vertical };
@@ -87,7 +90,7 @@ public sealed partial class LanguageMenuWindow : DefaultWindow, IEntityEventSubs
var button = new Button { Text = "Choose" };
button.OnPressed += _ => OnLanguageChosen(language);
state.button = button;
state.Button = button;
header.AddChild(name);
header.AddChild(button);
@@ -125,21 +128,18 @@ public sealed partial class LanguageMenuWindow : DefaultWindow, IEntityEventSubs
_entries.Add(state);
}
private void OnLanguageChosen(string id)
private void OnLanguageChosen(ProtoId<LanguagePrototype> id)
{
var proto = _clientLanguageSystem.GetLanguagePrototype(id);
if (proto == null)
return;
_clientLanguageSystem.RequestSetLanguage(id);
_clientLanguageSystem.RequestSetLanguage(proto);
UpdateState(id, _clientLanguageSystem.SpokenLanguages);
// Predict the change
if (_clientLanguageSystem.GetLocalSpeaker()?.SpokenLanguages is {} languages)
UpdateState(id, languages);
}
private struct EntryState
{
public string language;
public Button? button;
public ProtoId<LanguagePrototype> Language;
public Button? Button;
}
}

View File

@@ -1,81 +1,61 @@
using Content.Shared.Language;
using Content.Shared.Language.Components;
using Content.Shared.Language.Events;
using Content.Shared.Language.Systems;
using Robust.Client;
using Robust.Client.Player;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Client.Language.Systems;
/// <summary>
/// Client-side language system.
/// </summary>
/// <remarks>
/// Unlike the server, the client is not aware of other entities' languages; it's only notified about the entity that it posesses.
/// Due to that, this system stores such information in a static manner.
/// </remarks>
public sealed class LanguageSystem : SharedLanguageSystem
{
[Dependency] private readonly IBaseClient _client = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
/// <summary>
/// The current language of the entity currently possessed by the player.
/// Invoked when the languages of the local player entity change, for use in UI.
/// </summary>
public string CurrentLanguage { get; private set; } = default!;
/// <summary>
/// The list of languages the currently possessed entity can speak.
/// </summary>
public List<string> SpokenLanguages { get; private set; } = new();
/// <summary>
/// The list of languages the currently possessed entity can understand.
/// </summary>
public List<string> UnderstoodLanguages { get; private set; } = new();
public event EventHandler<LanguagesUpdatedMessage>? OnLanguagesChanged;
public event Action? OnLanguagesChanged;
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<LanguagesUpdatedMessage>(OnLanguagesUpdated);
_client.RunLevelChanged += OnRunLevelChanged;
_playerManager.LocalPlayerAttached += NotifyUpdate;
SubscribeLocalEvent<LanguageSpeakerComponent, ComponentHandleState>(OnHandleState);
}
private void OnLanguagesUpdated(LanguagesUpdatedMessage message)
private void OnHandleState(Entity<LanguageSpeakerComponent> ent, ref ComponentHandleState args)
{
// TODO this entire thing is horrible. If someone is willing to refactor this, LanguageSpeakerComponent should become shared with SendOnlyToOwner = true
// That way, this system will be able to use the existing networking infrastructure instead of relying on this makeshift... whatever this is.
CurrentLanguage = message.CurrentLanguage;
SpokenLanguages = message.Spoken;
UnderstoodLanguages = message.Understood;
if (args.Current is not LanguageSpeakerComponent.State state)
return;
OnLanguagesChanged?.Invoke(this, message);
}
ent.Comp.CurrentLanguage = state.CurrentLanguage;
ent.Comp.SpokenLanguages = state.SpokenLanguages;
ent.Comp.UnderstoodLanguages = state.UnderstoodLanguages;
private void OnRunLevelChanged(object? sender, RunLevelChangedEventArgs args)
{
// Request an update when entering a game
if (args.NewLevel == ClientRunLevel.InGame)
RequestStateUpdate();
if (ent.Owner == _playerManager.LocalEntity)
NotifyUpdate(ent);
}
/// <summary>
/// Sends a network request to the server to update this system's state.
/// The server may ignore the said request if the player is not possessing an entity.
/// Returns the LanguageSpeakerComponent of the local player entity.
/// Will return null if the player does not have an entity, or if the client has not yet received the component state.
/// </summary>
public void RequestStateUpdate()
public LanguageSpeakerComponent? GetLocalSpeaker()
{
RaiseNetworkEvent(new RequestLanguagesMessage());
return CompOrNull<LanguageSpeakerComponent>(_playerManager.LocalEntity);
}
public void RequestSetLanguage(LanguagePrototype language)
public void RequestSetLanguage(ProtoId<LanguagePrototype> language)
{
if (language.ID == CurrentLanguage)
if (GetLocalSpeaker()?.CurrentLanguage?.Equals(language) == true)
return;
RaiseNetworkEvent(new LanguagesSetMessage(language.ID));
RaiseNetworkEvent(new LanguagesSetMessage(language));
}
// May cause some minor desync...
// So to reduce the probability of desync, we replicate the change locally too
if (SpokenLanguages.Contains(language.ID))
CurrentLanguage = language.ID;
private void NotifyUpdate(EntityUid localPlayer)
{
RaiseLocalEvent(localPlayer, new LanguagesUpdateEvent(), broadcast: true);
OnLanguagesChanged?.Invoke();
}
}

View File

@@ -1,7 +1,6 @@
using System.Linq;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Language;
using Content.Server.Language.Events;
using Content.Server.Speech.Components;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.Language;

View File

@@ -21,9 +21,9 @@ using Content.Shared.SSDIndicator;
using Content.Shared.Damage.ForceSay;
using Content.Shared.Chat;
using Content.Server.Body.Components;
using Content.Server.Language;
using Content.Shared.Abilities.Psionics;
using Content.Shared.Language.Components;
using Content.Shared.Language;
using Content.Shared.Nutrition.Components;
using Robust.Shared.Enums;

View File

@@ -3,6 +3,7 @@ using Content.Shared.Administration;
using Content.Shared.Language;
using Content.Shared.Language.Components;
using Content.Shared.Language.Systems;
using Robust.Shared.Prototypes;
using Robust.Shared.Toolshed;
using Robust.Shared.Toolshed.Syntax;
using Robust.Shared.Toolshed.TypeParsers;
@@ -62,13 +63,13 @@ public sealed class AdminLanguageCommand : ToolshedCommand
}
[CommandImplementation("lsspoken")]
public IEnumerable<string> ListSpoken([PipedArgument] EntityUid input)
public IEnumerable<ProtoId<LanguagePrototype>> ListSpoken([PipedArgument] EntityUid input)
{
return Languages.GetSpokenLanguages(input);
}
[CommandImplementation("lsunderstood")]
public IEnumerable<string> ListUnderstood([PipedArgument] EntityUid input)
public IEnumerable<ProtoId<LanguagePrototype>> ListUnderstood([PipedArgument] EntityUid input)
{
return Languages.GetUnderstoodLanguages(input);
}

View File

@@ -6,6 +6,7 @@ using Content.Shared.Language.Components;
using Content.Shared.Language.Components.Translators;
using Content.Shared.Language.Systems;
using Robust.Server.Containers;
using Robust.Shared.Prototypes;
using Robust.Shared.Toolshed;
using Robust.Shared.Toolshed.Syntax;
using Robust.Shared.Toolshed.TypeParsers;
@@ -107,7 +108,7 @@ public sealed class AdminTranslatorCommand : ToolshedCommand
}
[CommandImplementation("lsspoken")]
public IEnumerable<string> ListSpoken([PipedArgument] EntityUid input)
public IEnumerable<ProtoId<LanguagePrototype>> ListSpoken([PipedArgument] EntityUid input)
{
if (!TryGetTranslatorComp(input, out var translator))
return [];
@@ -115,7 +116,7 @@ public sealed class AdminTranslatorCommand : ToolshedCommand
}
[CommandImplementation("lsunderstood")]
public IEnumerable<string> ListUnderstood([PipedArgument] EntityUid input)
public IEnumerable<ProtoId<LanguagePrototype>> ListUnderstood([PipedArgument] EntityUid input)
{
if (!TryGetTranslatorComp(input, out var translator))
return [];
@@ -123,7 +124,7 @@ public sealed class AdminTranslatorCommand : ToolshedCommand
}
[CommandImplementation("lsrequired")]
public IEnumerable<string> ListRequired([PipedArgument] EntityUid input)
public IEnumerable<ProtoId<LanguagePrototype>> ListRequired([PipedArgument] EntityUid input)
{
if (!TryGetTranslatorComp(input, out var translator))
return [];

View File

@@ -0,0 +1,23 @@
using Content.Shared.Language;
using Robust.Shared.Prototypes;
namespace Content.Server.Language;
/// <summary>
/// Stores data about entities' intrinsic language knowledge.
/// </summary>
[RegisterComponent]
public sealed partial class LanguageKnowledgeComponent : Component
{
/// <summary>
/// List of languages this entity can speak without any external tools.
/// </summary>
[DataField("speaks", required: true)]
public List<ProtoId<LanguagePrototype>> SpokenLanguages = new();
/// <summary>
/// List of languages this entity can understand without any external tools.
/// </summary>
[DataField("understands", required: true)]
public List<ProtoId<LanguagePrototype>> UnderstoodLanguages = new();
}

View File

@@ -1,73 +0,0 @@
using Content.Server.Language.Events;
using Content.Server.Mind;
using Content.Shared.Language;
using Content.Shared.Language.Components;
using Content.Shared.Language.Events;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;
using Robust.Shared.Player;
namespace Content.Server.Language;
public sealed partial class LanguageSystem
{
[Dependency] private readonly MindSystem _mind = default!;
public void InitializeNet()
{
SubscribeNetworkEvent<LanguagesSetMessage>(OnClientSetLanguage);
SubscribeNetworkEvent<RequestLanguagesMessage>((_, session) => SendLanguageStateToClient(session.SenderSession));
SubscribeLocalEvent<LanguageSpeakerComponent, LanguagesUpdateEvent>((uid, comp, _) => SendLanguageStateToClient(uid, comp));
// Refresh the client's state when its mind hops to a different entity
SubscribeLocalEvent<MindContainerComponent, MindAddedMessage>((uid, _, _) => SendLanguageStateToClient(uid));
SubscribeLocalEvent<MindComponent, MindGotRemovedEvent>((_, _, args) =>
{
if (args.Mind.Comp.Session != null)
SendLanguageStateToClient(args.Mind.Comp.Session);
});
}
private void OnClientSetLanguage(LanguagesSetMessage message, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not { Valid: true } uid)
return;
var language = GetLanguagePrototype(message.CurrentLanguage);
if (language == null || !CanSpeak(uid, language.ID))
return;
SetLanguage(uid, language.ID);
}
private void SendLanguageStateToClient(EntityUid uid, LanguageSpeakerComponent? comp = null)
{
// Try to find a mind inside the entity and notify its session
if (!_mind.TryGetMind(uid, out _, out var mindComp) || mindComp.Session == null)
return;
SendLanguageStateToClient(uid, mindComp.Session, comp);
}
private void SendLanguageStateToClient(ICommonSession session, LanguageSpeakerComponent? comp = null)
{
// Try to find an entity associated with the session and resolve the languages from it
if (session.AttachedEntity is not { Valid: true } entity)
return;
SendLanguageStateToClient(entity, session, comp);
}
// TODO this is really stupid and can be avoided if we just make everything shared...
private void SendLanguageStateToClient(EntityUid uid, ICommonSession session, LanguageSpeakerComponent? component = null)
{
var message = !Resolve(uid, ref component, logMissing: false)
? new LanguagesUpdatedMessage(UniversalPrototype, [UniversalPrototype], [UniversalPrototype])
: new LanguagesUpdatedMessage(component.CurrentLanguage, component.SpokenLanguages, component.UnderstoodLanguages);
RaiseNetworkEvent(message, session);
}
}

View File

@@ -1,9 +1,10 @@
using System.Linq;
using Content.Server.Language.Events;
using Content.Shared.Language;
using Content.Shared.Language.Components;
using Content.Shared.Language.Events;
using Content.Shared.Language.Systems;
using UniversalLanguageSpeakerComponent = Content.Shared.Language.Components.UniversalLanguageSpeakerComponent;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Server.Language;
@@ -12,55 +13,85 @@ public sealed partial class LanguageSystem : SharedLanguageSystem
public override void Initialize()
{
base.Initialize();
InitializeNet();
SubscribeLocalEvent<LanguageSpeakerComponent, ComponentInit>(OnInitLanguageSpeaker);
SubscribeLocalEvent<UniversalLanguageSpeakerComponent, MapInitEvent>(OnUniversalInit);
SubscribeLocalEvent<UniversalLanguageSpeakerComponent, ComponentShutdown>(OnUniversalShutdown);
SubscribeLocalEvent<LanguageSpeakerComponent, MapInitEvent>(OnInitLanguageSpeaker);
SubscribeLocalEvent<LanguageSpeakerComponent, ComponentGetState>(OnGetLanguageState);
SubscribeLocalEvent<UniversalLanguageSpeakerComponent, DetermineEntityLanguagesEvent>(OnDetermineUniversalLanguages);
SubscribeNetworkEvent<LanguagesSetMessage>(OnClientSetLanguage);
SubscribeLocalEvent<UniversalLanguageSpeakerComponent, MapInitEvent>((uid, _, _) => UpdateEntityLanguages(uid));
SubscribeLocalEvent<UniversalLanguageSpeakerComponent, ComponentRemove>((uid, _, _) => UpdateEntityLanguages(uid));
}
private void OnUniversalShutdown(EntityUid uid, UniversalLanguageSpeakerComponent component, ComponentShutdown args)
#region event handling
private void OnInitLanguageSpeaker(Entity<LanguageSpeakerComponent> ent, ref MapInitEvent args)
{
RemoveLanguage(uid, UniversalPrototype);
if (string.IsNullOrEmpty(ent.Comp.CurrentLanguage))
ent.Comp.CurrentLanguage = ent.Comp.SpokenLanguages.FirstOrDefault(UniversalPrototype);
UpdateEntityLanguages(ent!);
}
private void OnUniversalInit(EntityUid uid, UniversalLanguageSpeakerComponent component, MapInitEvent args)
private void OnGetLanguageState(Entity<LanguageSpeakerComponent> entity, ref ComponentGetState args)
{
AddLanguage(uid, UniversalPrototype);
args.State = new LanguageSpeakerComponent.State
{
CurrentLanguage = entity.Comp.CurrentLanguage,
SpokenLanguages = entity.Comp.SpokenLanguages,
UnderstoodLanguages = entity.Comp.UnderstoodLanguages
};
}
private void OnDetermineUniversalLanguages(Entity<UniversalLanguageSpeakerComponent> entity, ref DetermineEntityLanguagesEvent ev)
{
// We only add it as a spoken language; CanUnderstand checks for ULSC itself.
if (entity.Comp.Enabled)
ev.SpokenLanguages.Add(UniversalPrototype);
}
private void OnClientSetLanguage(LanguagesSetMessage message, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not { Valid: true } uid)
return;
var language = GetLanguagePrototype(message.CurrentLanguage);
if (language == null || !CanSpeak(uid, language.ID))
return;
SetLanguage(uid, language.ID);
}
#endregion
#region public api
public bool CanUnderstand(EntityUid listener, string language, LanguageSpeakerComponent? component = null)
public bool CanUnderstand(Entity<LanguageSpeakerComponent?> ent, ProtoId<LanguagePrototype> language)
{
if (language == UniversalPrototype || HasComp<UniversalLanguageSpeakerComponent>(listener))
if (language == UniversalPrototype || TryComp<UniversalLanguageSpeakerComponent>(ent, out var uni) && uni.Enabled)
return true;
if (!Resolve(listener, ref component, logMissing: false))
return false;
return component.UnderstoodLanguages.Contains(language);
return Resolve(ent, ref ent.Comp, logMissing: false) && ent.Comp.UnderstoodLanguages.Contains(language);
}
public bool CanSpeak(EntityUid speaker, string language, LanguageSpeakerComponent? component = null)
public bool CanSpeak(Entity<LanguageSpeakerComponent?> ent, ProtoId<LanguagePrototype> language)
{
if (HasComp<UniversalLanguageSpeakerComponent>(speaker))
return true;
if (!Resolve(speaker, ref component, logMissing: false))
if (!Resolve(ent, ref ent.Comp, logMissing: false))
return false;
return component.SpokenLanguages.Contains(language);
return ent.Comp.SpokenLanguages.Contains(language);
}
/// <summary>
/// Returns the current language of the given entity, assumes Universal if it's not a language speaker.
/// </summary>
public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent? component = null)
public LanguagePrototype GetLanguage(Entity<LanguageSpeakerComponent?> ent)
{
if (!Resolve(speaker, ref component, logMissing: false)
|| string.IsNullOrEmpty(component.CurrentLanguage)
|| !_prototype.TryIndex<LanguagePrototype>(component.CurrentLanguage, out var proto))
if (!Resolve(ent, ref ent.Comp, logMissing: false)
|| string.IsNullOrEmpty(ent.Comp.CurrentLanguage)
|| !_prototype.TryIndex<LanguagePrototype>(ent.Comp.CurrentLanguage, out var proto)
)
return Universal;
return proto;
@@ -69,36 +100,31 @@ public sealed partial class LanguageSystem : SharedLanguageSystem
/// <summary>
/// Returns the list of languages this entity can speak.
/// </summary>
/// <remarks>Typically, checking <see cref="LanguageSpeakerComponent.SpokenLanguages"/> is sufficient.</remarks>
public List<string> GetSpokenLanguages(EntityUid uid)
/// <remarks>This simply returns the value of <see cref="LanguageSpeakerComponent.SpokenLanguages"/>.</remarks>
public List<ProtoId<LanguagePrototype>> GetSpokenLanguages(EntityUid uid)
{
if (!TryComp<LanguageSpeakerComponent>(uid, out var component))
return [];
return component.SpokenLanguages;
return TryComp<LanguageSpeakerComponent>(uid, out var component) ? component.SpokenLanguages : [];
}
/// <summary>
/// Returns the list of languages this entity can understand.
/// </summary>
/// <remarks>Typically, checking <see cref="LanguageSpeakerComponent.UnderstoodLanguages"/> is sufficient.</remarks>
public List<string> GetUnderstoodLanguages(EntityUid uid)
/// </summary
/// <remarks>This simply returns the value of <see cref="LanguageSpeakerComponent.SpokenLanguages"/>.</remarks>
public List<ProtoId<LanguagePrototype>> GetUnderstoodLanguages(EntityUid uid)
{
if (!TryComp<LanguageSpeakerComponent>(uid, out var component))
return [];
return component.UnderstoodLanguages;
return TryComp<LanguageSpeakerComponent>(uid, out var component) ? component.UnderstoodLanguages : [];
}
public void SetLanguage(EntityUid speaker, string language, LanguageSpeakerComponent? component = null)
public void SetLanguage(Entity<LanguageSpeakerComponent?> ent, ProtoId<LanguagePrototype> language)
{
if (!CanSpeak(speaker, language)
|| !Resolve(speaker, ref component)
|| component.CurrentLanguage == language)
if (!CanSpeak(ent, language)
|| !Resolve(ent, ref ent.Comp)
|| ent.Comp.CurrentLanguage == language)
return;
component.CurrentLanguage = language;
RaiseLocalEvent(speaker, new LanguagesUpdateEvent(), true);
ent.Comp.CurrentLanguage = language;
RaiseLocalEvent(ent, new LanguagesUpdateEvent(), true);
Dirty(ent);
}
/// <summary>
@@ -106,12 +132,12 @@ public sealed partial class LanguageSystem : SharedLanguageSystem
/// </summary>
public void AddLanguage(
EntityUid uid,
string language,
ProtoId<LanguagePrototype> language,
bool addSpoken = true,
bool addUnderstood = true)
{
EnsureComp<LanguageKnowledgeComponent>(uid, out var knowledge);
EnsureComp<LanguageSpeakerComponent>(uid);
EnsureComp<LanguageSpeakerComponent>(uid, out var speaker);
if (addSpoken && !knowledge.SpokenLanguages.Contains(language))
knowledge.SpokenLanguages.Add(language);
@@ -119,28 +145,29 @@ public sealed partial class LanguageSystem : SharedLanguageSystem
if (addUnderstood && !knowledge.UnderstoodLanguages.Contains(language))
knowledge.UnderstoodLanguages.Add(language);
UpdateEntityLanguages(uid);
UpdateEntityLanguages((uid, speaker));
}
/// <summary>
/// Removes a language from the respective lists of intrinsically known languages of the given entity.
/// </summary>
public void RemoveLanguage(
EntityUid uid,
string language,
Entity<LanguageKnowledgeComponent?> ent,
ProtoId<LanguagePrototype> language,
bool removeSpoken = true,
bool removeUnderstood = true)
{
if (!TryComp<LanguageKnowledgeComponent>(uid, out var knowledge))
if (!Resolve(ent, ref ent.Comp, false))
return;
if (removeSpoken)
knowledge.SpokenLanguages.Remove(language);
ent.Comp.SpokenLanguages.Remove(language);
if (removeUnderstood)
knowledge.UnderstoodLanguages.Remove(language);
ent.Comp.UnderstoodLanguages.Remove(language);
UpdateEntityLanguages(uid);
// We don't ensure that the entity has a speaker comp. If it doesn't... Well, woe be the caller of this method.
UpdateEntityLanguages(ent.Owner);
}
/// <summary>
@@ -148,15 +175,16 @@ public sealed partial class LanguageSystem : SharedLanguageSystem
/// If not, sets it to the first entry of its SpokenLanguages list, or universal if it's empty.
/// </summary>
/// <returns>True if the current language was modified, false otherwise.</returns>
public bool EnsureValidLanguage(EntityUid entity, LanguageSpeakerComponent? comp = null)
public bool EnsureValidLanguage(Entity<LanguageSpeakerComponent?> ent)
{
if (!Resolve(entity, ref comp))
if (!Resolve(ent, ref ent.Comp, false))
return false;
if (!comp.SpokenLanguages.Contains(comp.CurrentLanguage))
if (!ent.Comp.SpokenLanguages.Contains(ent.Comp.CurrentLanguage))
{
comp.CurrentLanguage = comp.SpokenLanguages.FirstOrDefault(UniversalPrototype);
RaiseLocalEvent(entity, new LanguagesUpdateEvent());
ent.Comp.CurrentLanguage = ent.Comp.SpokenLanguages.FirstOrDefault(UniversalPrototype);
RaiseLocalEvent(ent, new LanguagesUpdateEvent());
Dirty(ent);
return true;
}
@@ -166,14 +194,14 @@ public sealed partial class LanguageSystem : SharedLanguageSystem
/// <summary>
/// Immediately refreshes the cached lists of spoken and understood languages for the given entity.
/// </summary>
public void UpdateEntityLanguages(EntityUid entity)
public void UpdateEntityLanguages(Entity<LanguageSpeakerComponent?> ent)
{
if (!TryComp<LanguageSpeakerComponent>(entity, out var languages))
if (!Resolve(ent, ref ent.Comp, false))
return;
var ev = new DetermineEntityLanguagesEvent();
// We add the intrinsically known languages first so other systems can manipulate them easily
if (TryComp<LanguageKnowledgeComponent>(entity, out var knowledge))
if (TryComp<LanguageKnowledgeComponent>(ent, out var knowledge))
{
foreach (var spoken in knowledge.SpokenLanguages)
ev.SpokenLanguages.Add(spoken);
@@ -182,28 +210,19 @@ public sealed partial class LanguageSystem : SharedLanguageSystem
ev.UnderstoodLanguages.Add(understood);
}
RaiseLocalEvent(entity, ref ev);
RaiseLocalEvent(ent, ref ev);
languages.SpokenLanguages.Clear();
languages.UnderstoodLanguages.Clear();
ent.Comp.SpokenLanguages.Clear();
ent.Comp.UnderstoodLanguages.Clear();
languages.SpokenLanguages.AddRange(ev.SpokenLanguages);
languages.UnderstoodLanguages.AddRange(ev.UnderstoodLanguages);
ent.Comp.SpokenLanguages.AddRange(ev.SpokenLanguages);
ent.Comp.UnderstoodLanguages.AddRange(ev.UnderstoodLanguages);
if (!EnsureValidLanguage(entity))
RaiseLocalEvent(entity, new LanguagesUpdateEvent());
}
// If EnsureValidLanguage returns true, it also raises a LanguagesUpdateEvent, so we try to avoid raising it twice in that case.
if (!EnsureValidLanguage(ent))
RaiseLocalEvent(ent, new LanguagesUpdateEvent());
#endregion
#region event handling
private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args)
{
if (string.IsNullOrEmpty(component.CurrentLanguage))
component.CurrentLanguage = component.SpokenLanguages.FirstOrDefault(UniversalPrototype);
UpdateEntityLanguages(uid);
Dirty(ent);
}
#endregion

View File

@@ -1,8 +0,0 @@
namespace Content.Server.Language.Events;
/// <summary>
/// Raised on an entity when its list of languages changes.
/// </summary>
public sealed class LanguagesUpdateEvent : EntityEventArgs
{
}

View File

@@ -1,6 +1,7 @@
using Content.Shared.Implants.Components;
using Content.Shared.Language;
using Content.Shared.Language.Components;
using Content.Shared.Language.Events;
using Robust.Shared.Containers;
namespace Content.Server.Language;

View File

@@ -8,7 +8,9 @@ using Content.Shared.Language.Components;
using Content.Shared.Language.Systems;
using Content.Shared.PowerCell;
using Content.Shared.Language.Components.Translators;
using Content.Shared.Language.Events;
using Robust.Shared.Containers;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
namespace Content.Server.Language;
@@ -112,7 +114,7 @@ public sealed class TranslatorSystem : SharedTranslatorSystem
// Update the current language of the entity if necessary
if (isEnabled && translatorComp.SetLanguageOnInteract && firstNewLanguage is {})
_language.SetLanguage(holder, firstNewLanguage, languageComp);
_language.SetLanguage((holder, languageComp), firstNewLanguage);
}
OnAppearanceChange(translator, translatorComp);
@@ -152,7 +154,7 @@ public sealed class TranslatorSystem : SharedTranslatorSystem
/// <summary>
/// Checks whether any OR all required languages are provided. Used for utility purposes.
/// </summary>
public static bool CheckLanguagesMatch(ICollection<string> required, ICollection<string> provided, bool requireAll)
public static bool CheckLanguagesMatch(ICollection<ProtoId<LanguagePrototype>> required, ICollection<ProtoId<LanguagePrototype>> provided, bool requireAll)
{
if (required.Count == 0)
return true;

View File

@@ -1,4 +1,4 @@
namespace Content.Shared.Language.Components;
namespace Content.Server.Language;
// <summary>
// Signifies that this entity can speak and understand any language.
@@ -7,5 +7,6 @@ namespace Content.Shared.Language.Components;
[RegisterComponent]
public sealed partial class UniversalLanguageSpeakerComponent : Component
{
[DataField]
public bool Enabled = true;
}

View File

@@ -3,7 +3,7 @@ using Content.Server.Language;
using Content.Shared.Administration;
using Content.Shared.Emoting;
using Content.Shared.Examine;
using Content.Shared.Language;
using Content.Shared.Language.Components;
using Content.Shared.Language.Systems;
using Content.Shared.Mind.Components;
using Content.Shared.Movement.Components;

View File

@@ -38,7 +38,7 @@ public sealed partial class ForeignerTraitSystem : EntitySystem
}
var alternateLanguage = knowledge.SpokenLanguages.Find(it => it != entity.Comp.BaseLanguage);
if (alternateLanguage == null)
if (alternateLanguage == default)
{
Log.Warning($"Entity {entity.Owner} does not have an alternative language to choose from (must have at least one non-GC for ForeignerTrait)!");
return;
@@ -46,12 +46,12 @@ public sealed partial class ForeignerTraitSystem : EntitySystem
if (TryGiveTranslator(entity.Owner, entity.Comp.BaseTranslator, entity.Comp.BaseLanguage, alternateLanguage, out var translator))
{
_languages.RemoveLanguage(entity, entity.Comp.BaseLanguage, entity.Comp.CantSpeak, entity.Comp.CantUnderstand);
_languages.RemoveLanguage(entity.Owner, entity.Comp.BaseLanguage, entity.Comp.CantSpeak, entity.Comp.CantUnderstand);
}
}
/// <summary>
/// Tries to create and give the entity a translator to translator that translates speech between the two specified languages.
/// Tries to create and give the entity a translator that translates speech between the two specified languages.
/// </summary>
public bool TryGiveTranslator(
EntityUid uid,

View File

@@ -1,24 +0,0 @@
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
namespace Content.Shared.Language.Components;
// TODO: move to server side, it's never synchronized!
/// <summary>
/// Stores data about entities' intrinsic language knowledge.
/// </summary>
[RegisterComponent]
public sealed partial class LanguageKnowledgeComponent : Component
{
/// <summary>
/// List of languages this entity can speak without any external tools.
/// </summary>
[DataField("speaks", customTypeSerializer: typeof(PrototypeIdListSerializer<LanguagePrototype>), required: true)]
public List<string> SpokenLanguages = new();
/// <summary>
/// List of languages this entity can understand without any external tools.
/// </summary>
[DataField("understands", customTypeSerializer: typeof(PrototypeIdListSerializer<LanguagePrototype>), required: true)]
public List<string> UnderstoodLanguages = new();
}

View File

@@ -1,7 +1,9 @@
namespace Content.Shared.Language;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations;
// TODO: either move all language speaker-related components to server side, or make everything else shared.
// The current approach leads to confusion, as the server never informs the client of updates in these components.
namespace Content.Shared.Language.Components;
/// <summary>
/// Stores the current state of the languages the entity can speak and understand.
@@ -10,23 +12,35 @@ namespace Content.Shared.Language;
/// All fields of this component are populated during a DetermineEntityLanguagesEvent.
/// They are not to be modified externally.
/// </remarks>
[RegisterComponent]
[RegisterComponent, NetworkedComponent]
public sealed partial class LanguageSpeakerComponent : Component
{
public override bool SendOnlyToOwner => true;
/// <summary>
/// The current language the entity uses when speaking.
/// Other listeners will hear the entity speak in this language.
/// </summary>
[DataField]
public string CurrentLanguage = ""; // The language system will override it on init
public string CurrentLanguage = ""; // The language system will override it on mapinit
/// <summary>
/// List of languages this entity can speak at the current moment.
/// </summary>
public List<string> SpokenLanguages = [];
[DataField]
public List<ProtoId<LanguagePrototype>> SpokenLanguages = [];
/// <summary>
/// List of languages this entity can understand at the current moment.
/// </summary>
public List<string> UnderstoodLanguages = [];
[DataField]
public List<ProtoId<LanguagePrototype>> UnderstoodLanguages = [];
[Serializable, NetSerializable]
public sealed class State : ComponentState
{
public string CurrentLanguage = default!;
public List<ProtoId<LanguagePrototype>> SpokenLanguages = default!;
public List<ProtoId<LanguagePrototype>> UnderstoodLanguages = default!;
}
}

View File

@@ -1,3 +1,4 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
namespace Content.Shared.Language.Components.Translators;
@@ -7,21 +8,21 @@ public abstract partial class BaseTranslatorComponent : Component
/// <summary>
/// The list of additional languages this translator allows the wielder to speak.
/// </summary>
[DataField("spoken", customTypeSerializer: typeof(PrototypeIdListSerializer<LanguagePrototype>))]
public List<string> SpokenLanguages = new();
[DataField("spoken")]
public List<ProtoId<LanguagePrototype>> SpokenLanguages = new();
/// <summary>
/// The list of additional languages this translator allows the wielder to understand.
/// </summary>
[DataField("understood", customTypeSerializer: typeof(PrototypeIdListSerializer<LanguagePrototype>))]
public List<string> UnderstoodLanguages = new();
[DataField("understood")]
public List<ProtoId<LanguagePrototype>> UnderstoodLanguages = new();
/// <summary>
/// The languages the wielding MUST know in order for this translator to have effect.
/// The field [RequiresAllLanguages] indicates whether all of them are required, or just one.
/// </summary>
[DataField("requires", customTypeSerializer: typeof(PrototypeIdListSerializer<LanguagePrototype>))]
public List<string> RequiredLanguages = new();
[DataField("requires")]
public List<ProtoId<LanguagePrototype>> RequiredLanguages = new();
/// <summary>
/// If true, the wielder must understand all languages in [RequiredLanguages] to speak [SpokenLanguages],
@@ -30,9 +31,8 @@ public abstract partial class BaseTranslatorComponent : Component
/// Otherwise, at least one language must be known (or the list must be empty).
/// </summary>
[DataField("requiresAll")]
[ViewVariables(VVAccess.ReadWrite)]
public bool RequiresAllLanguages = false;
[DataField("enabled"), ViewVariables(VVAccess.ReadWrite)]
[DataField("enabled")]
public bool Enabled = true;
}

View File

@@ -4,7 +4,7 @@ namespace Content.Shared.Language.Components.Translators;
/// A translator that must be held in a hand or a pocket of an entity in order ot have effect.
/// </summary>
[RegisterComponent]
public sealed partial class HandheldTranslatorComponent : Translators.BaseTranslatorComponent
public sealed partial class HandheldTranslatorComponent : BaseTranslatorComponent
{
/// <summary>
/// Whether interacting with this translator toggles it on and off.

View File

@@ -1,6 +1,6 @@
using Content.Shared.Language;
using Robust.Shared.Prototypes;
namespace Content.Server.Language;
namespace Content.Shared.Language.Events;
/// <summary>
/// Raised in order to determine the list of languages the entity can speak and understand at the given moment.
@@ -13,13 +13,13 @@ public record struct DetermineEntityLanguagesEvent
/// The list of all languages the entity may speak.
/// By default, contains the languages this entity speaks intrinsically.
/// </summary>
public HashSet<string> SpokenLanguages = new();
public HashSet<ProtoId<LanguagePrototype>> SpokenLanguages = new();
/// <summary>
/// The list of all languages the entity may understand.
/// By default, contains the languages this entity understands intrinsically.
/// </summary>
public HashSet<string> UnderstoodLanguages = new();
public HashSet<ProtoId<LanguagePrototype>> UnderstoodLanguages = new();
public DetermineEntityLanguagesEvent() {}
}

View File

@@ -0,0 +1,12 @@
namespace Content.Shared.Language.Events;
/// <summary>
/// Raised on an entity when its list of languages changes.
/// </summary>
/// <remarks>
/// This is raised both on the server and on the client.
/// The client raises it broadcast after receiving a new language comp state from the server.
/// </remarks>
public sealed class LanguagesUpdateEvent : EntityEventArgs
{
}

View File

@@ -1,15 +0,0 @@
using Robust.Shared.Serialization;
namespace Content.Shared.Language.Events;
/// <summary>
/// Sent to the client when its list of languages changes.
/// The client should in turn update its HUD and relevant systems.
/// </summary>
[Serializable, NetSerializable]
public sealed class LanguagesUpdatedMessage(string currentLanguage, List<string> spoken, List<string> understood) : EntityEventArgs
{
public string CurrentLanguage = currentLanguage;
public List<string> Spoken = spoken;
public List<string> Understood = understood;
}

View File

@@ -1,10 +0,0 @@
using Robust.Shared.Serialization;
namespace Content.Shared.Language.Events;
/// <summary>
/// Sent from the client to the server when it needs to learn the list of languages its entity knows.
/// This event should always be followed by a <see cref="LanguagesUpdatedMessage"/>, unless the client doesn't have an entity.
/// </summary>
[Serializable, NetSerializable]
public sealed class RequestLanguagesMessage : EntityEventArgs;

View File

@@ -31,9 +31,9 @@ public abstract class SharedLanguageSystem : EntitySystem
Universal = _prototype.Index<LanguagePrototype>("Universal");
}
public LanguagePrototype? GetLanguagePrototype(string id)
public LanguagePrototype? GetLanguagePrototype(ProtoId<LanguagePrototype> id)
{
_prototype.TryIndex<LanguagePrototype>(id, out var proto);
_prototype.TryIndex(id, out var proto);
return proto;
}
@@ -43,8 +43,7 @@ public abstract class SharedLanguageSystem : EntitySystem
public string ObfuscateSpeech(string message, LanguagePrototype language)
{
var builder = new StringBuilder();
var method = language.Obfuscation;
method.Obfuscate(builder, message, this);
language.Obfuscation.Obfuscate(builder, message, this);
return builder.ToString();
}