diff --git a/Content.Server/Language/TranslatorSystem.cs b/Content.Server/Language/TranslatorSystem.cs
index 5022e540960..adbfe2d681f 100644
--- a/Content.Server/Language/TranslatorSystem.cs
+++ b/Content.Server/Language/TranslatorSystem.cs
@@ -1,15 +1,12 @@
using System.Linq;
-using Content.Server.Language.Events;
using Content.Server.Popups;
using Content.Server.PowerCell;
using Content.Shared.Interaction;
using Content.Shared.Interaction.Events;
using Content.Shared.Language;
-using Content.Shared.Language.Events;
using Content.Shared.Language.Systems;
using Content.Shared.PowerCell;
using Content.Shared.Language.Components.Translators;
-using Robust.Shared.Utility;
namespace Content.Server.Language;
diff --git a/Content.Server/Traits/Assorted/ForeignerTraitComponent.cs b/Content.Server/Traits/Assorted/ForeignerTraitComponent.cs
new file mode 100644
index 00000000000..e2d74ba5d9b
--- /dev/null
+++ b/Content.Server/Traits/Assorted/ForeignerTraitComponent.cs
@@ -0,0 +1,36 @@
+using Content.Shared.Language;
+using Content.Shared.Language.Systems;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Traits.Assorted;
+
+///
+/// When applied to a not-yet-spawned player entity, removes from the lists of their languages
+/// and gives them a translator instead.
+///
+[RegisterComponent]
+public sealed partial class ForeignerTraitComponent : Component
+{
+ ///
+ /// The "base" language that is to be removed and substituted with a translator.
+ /// By default, equals to the fallback language, which is GalacticCommon.
+ ///
+ [DataField]
+ public ProtoId BaseLanguage = SharedLanguageSystem.FallbackLanguagePrototype;
+
+ ///
+ /// Whether this trait prevents the entity from understanding the base language.
+ ///
+ public bool CantUnderstand = true;
+
+ ///
+ /// Whether this trait prevents the entity from speaking the base language.
+ ///
+ public bool CantSpeak = true;
+
+ ///
+ /// The base translator prototype to use when creating a translator for the entity.
+ ///
+ [DataField(required: true)]
+ public ProtoId BaseTranslator = default!;
+}
diff --git a/Content.Server/Traits/Assorted/ForeignerTraitSystem.cs b/Content.Server/Traits/Assorted/ForeignerTraitSystem.cs
new file mode 100644
index 00000000000..58e974227ce
--- /dev/null
+++ b/Content.Server/Traits/Assorted/ForeignerTraitSystem.cs
@@ -0,0 +1,105 @@
+using System.Linq;
+using Content.Server.Hands.Systems;
+using Content.Server.Language;
+using Content.Server.Storage.EntitySystems;
+using Content.Shared.Clothing.Components;
+using Content.Shared.Inventory;
+using Content.Shared.Language;
+using Content.Shared.Language.Components;
+using Content.Shared.Language.Components.Translators;
+using Content.Shared.Storage;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Traits.Assorted;
+
+
+public sealed partial class ForeignerTraitSystem : EntitySystem
+{
+ [Dependency] private readonly EntityManager _entMan = default!;
+ [Dependency] private readonly HandsSystem _hands = default!;
+ [Dependency] private readonly InventorySystem _inventory = default!;
+ [Dependency] private readonly LanguageSystem _languages = default!;
+ [Dependency] private readonly StorageSystem _storage = default!;
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnSpawn); // TraitSystem adds it after PlayerSpawnCompleteEvent so it's fine
+ }
+
+ private void OnSpawn(Entity entity, ref ComponentInit args)
+ {
+ if (entity.Comp.CantUnderstand && !entity.Comp.CantSpeak)
+ Log.Warning($"Allowing entity {entity.Owner} to speak a language but not understand it leads to undefined behavior.");
+
+ if (!TryComp(entity, out var knowledge))
+ {
+ Log.Warning($"Entity {entity.Owner} does not have a LanguageKnowledge but has a ForeignerTrait!");
+ return;
+ }
+
+ var alternateLanguage = knowledge.SpokenLanguages.Find(it => it != entity.Comp.BaseLanguage);
+ if (alternateLanguage == null)
+ {
+ Log.Warning($"Entity {entity.Owner} does not have an alternative language to choose from (must have at least one non-GC for ForeignerTrait)!");
+ return;
+ }
+
+ 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, knowledge);
+ }
+ }
+
+ ///
+ /// Tries to create and give the entity a translator to translator that translates speech between the two specified languages.
+ ///
+ public bool TryGiveTranslator(
+ EntityUid uid,
+ string baseTranslatorPrototype,
+ ProtoId translatorLanguage,
+ ProtoId entityLanguage,
+ out EntityUid result)
+ {
+ result = EntityUid.Invalid;
+ if (translatorLanguage == entityLanguage)
+ return false;
+
+ var translator = _entMan.SpawnNextToOrDrop(baseTranslatorPrototype, uid);
+ result = translator;
+
+ if (!TryComp(translator, out var handheld))
+ {
+ handheld = AddComp(translator);
+ handheld.ToggleOnInteract = true;
+ handheld.SetLanguageOnInteract = true;
+ }
+
+ // Allows to speak the specified language and requires entities language.
+ handheld.SpokenLanguages = [translatorLanguage];
+ handheld.UnderstoodLanguages = [translatorLanguage];
+ handheld.RequiredLanguages = [entityLanguage];
+
+ // Try to put it in entities hand
+ if (_hands.TryPickupAnyHand(uid, translator, false, false, false))
+ return true;
+
+ // Try to find a valid clothing slot on the entity and equip the translator there
+ if (TryComp(translator, out var clothing)
+ && clothing.Slots != SlotFlags.NONE
+ && _inventory.TryGetSlots(uid, out var slots)
+ && slots.Any(it => _inventory.TryEquip(uid, translator, it.Name, true, false)))
+ return true;
+
+ // Try to put the translator into entities bag, if it has one
+ if (_inventory.TryGetSlotEntity(uid, "back", out var bag)
+ && TryComp(bag, out var storage)
+ && _storage.Insert(bag.Value, translator, out _, null, storage, false, false))
+ return true;
+
+ // If all of the above has failed, just drop it at the same location as the entity
+ // This should ideally never happen, but who knows.
+ Transform(translator).Coordinates = Transform(uid).Coordinates;
+
+ return true;
+ }
+}
diff --git a/Content.Shared/Language/Components/LanguageKnowledgeComponent.cs b/Content.Shared/Language/Components/LanguageKnowledgeComponent.cs
index 0632f5d9cb2..ddbdc742be4 100644
--- a/Content.Shared/Language/Components/LanguageKnowledgeComponent.cs
+++ b/Content.Shared/Language/Components/LanguageKnowledgeComponent.cs
@@ -2,6 +2,8 @@
namespace Content.Shared.Language.Components;
+// TODO: move to server side, it's never synchronized!
+
///
/// Stores data about entities' intrinsic language knowledge.
///
diff --git a/Content.Shared/Language/Systems/SharedTranslatorSystem.cs b/Content.Shared/Language/Systems/SharedTranslatorSystem.cs
index 08a016efa9c..4a72de791f0 100644
--- a/Content.Shared/Language/Systems/SharedTranslatorSystem.cs
+++ b/Content.Shared/Language/Systems/SharedTranslatorSystem.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using Content.Shared.Examine;
using Content.Shared.Toggleable;
using Content.Shared.Language.Components.Translators;
@@ -17,11 +18,20 @@ public override void Initialize()
private void OnExamined(EntityUid uid, HandheldTranslatorComponent component, ExaminedEvent args)
{
- var state = Loc.GetString(component.Enabled
- ? "translator-enabled"
- : "translator-disabled");
+ var understoodLanguageNames = component.UnderstoodLanguages
+ .Select(it => Loc.GetString($"language-{it}-name"));
+ var spokenLanguageNames = component.SpokenLanguages
+ .Select(it => Loc.GetString($"language-{it}-name"));
+ var requiredLanguageNames = component.RequiredLanguages
+ .Select(it => Loc.GetString($"language-{it}-name"));
- args.PushMarkup(state);
+ args.PushMarkup(Loc.GetString("translator-examined-langs-understood", ("languages", string.Join(", ", understoodLanguageNames))));
+ args.PushMarkup(Loc.GetString("translator-examined-langs-spoken", ("languages", string.Join(", ", spokenLanguageNames))));
+
+ args.PushMarkup(Loc.GetString(component.RequiresAllLanguages ? "translator-examined-requires-all" : "translator-examined-requires-any",
+ ("languages", string.Join(", ", requiredLanguageNames))));
+
+ args.PushMarkup(Loc.GetString(component.Enabled ? "translator-examined-enabled" : "translator-examined-disabled"));
}
protected void OnAppearanceChange(EntityUid translator, HandheldTranslatorComponent? comp = null)
diff --git a/Resources/Locale/en-US/language/translator.ftl b/Resources/Locale/en-US/language/translator.ftl
index b2a1e9b2b8c..8070d03be29 100644
--- a/Resources/Locale/en-US/language/translator.ftl
+++ b/Resources/Locale/en-US/language/translator.ftl
@@ -1,8 +1,13 @@
translator-component-shutoff = The {$translator} shuts off.
translator-component-turnon = The {$translator} turns on.
-translator-enabled = It appears to be active.
-translator-disabled = It appears to be disabled.
translator-implanter-refuse = The {$implanter} has no effect on {$target}.
translator-implanter-success = The {$implanter} successfully injected {$target}.
translator-implanter-ready = This implanter appears to be ready to use.
translator-implanter-used = This implanter seems empty.
+
+translator-examined-langs-understood = It can translate from: [color=green]{$languages}[/color].
+translator-examined-langs-spoken = It can translate to: [color=green]{$languages}[/color].
+translator-examined-requires-any = It requires you to know at least one of these languages: [color=yellow]{$languages}[/color].
+translator-examined-requires-all = It requires you to know all of these languages: [color=yellow]{$languages}[/color].
+translator-examined-enabled = It appears to be [color=green]active[/color].
+translator-examined-disabled = It appears to be [color=red]turned off[/color].
diff --git a/Resources/Locale/en-US/traits/traits.ftl b/Resources/Locale/en-US/traits/traits.ftl
index 2f59d322282..f6e3e0b1fc1 100644
--- a/Resources/Locale/en-US/traits/traits.ftl
+++ b/Resources/Locale/en-US/traits/traits.ftl
@@ -42,3 +42,13 @@ trait-name-Thieving = Thieving
trait-description-Thieving =
You are deft with your hands, and talented at convincing people of their belongings.
You can identify pocketed items, steal them quieter, and steal ~33% faster.
+
+trait-name-ForeignerLight = Foreigner (light)
+trait-description-ForeignerLight =
+ You struggle to learn this station's primary language, and as such, cannot speak it. You can, however, comprehend what others say in that language.
+ To help you overcome this obstacle, you are equipped with a translator that helps you speak in this station's primary language.
+
+trait-name-Foreigner = Foreigner
+trait-description-Foreigner =
+ For one reason or another you do not speak this station's primary language.
+ Instead, you have a translator issued to you that only you can use.
diff --git a/Resources/Prototypes/Entities/Objects/Devices/translators.yml b/Resources/Prototypes/Entities/Objects/Devices/translators.yml
index 664626ea4b4..b28541253d4 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/translators.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/translators.yml
@@ -1,5 +1,5 @@
- type: entity
- abstract: true
+ noSpawn: true
id: TranslatorUnpowered
parent: BaseItem
name: translator
@@ -23,9 +23,14 @@
False: { visible: false }
- type: HandheldTranslator
enabled: false
+ - type: Clothing # To allow equipping translators on the neck slot
+ slots: [neck, pocket]
+ equipDelay: 0.3
+ unequipDelay: 0.3
+ quickEquip: false # Would conflict
- type: entity
- abstract: true
+ noSpawn: true
id: Translator
parent: [ TranslatorUnpowered, PowerCellSlotMediumItem ]
suffix: Powered
@@ -34,7 +39,7 @@
drawRate: 1
- type: entity
- abstract: true
+ noSpawn: true
id: TranslatorEmpty
parent: Translator
suffix: Empty
@@ -44,6 +49,13 @@
cell_slot:
name: power-cell-slot-component-slot-name-default
+- type: entity
+ noSpawn: true
+ id: TranslatorForeigner
+ parent: [ Translator, PowerCellSlotHighItem ]
+ name: foreigner's translator
+ description: A special-issue translator that helps foreigner's speak and understand this station's primary language.
+
- type: entity
id: CanilunztTranslator
diff --git a/Resources/Prototypes/Traits/inconveniences.yml b/Resources/Prototypes/Traits/inconveniences.yml
index dcf53d9ab7f..8dc0264ffea 100644
--- a/Resources/Prototypes/Traits/inconveniences.yml
+++ b/Resources/Prototypes/Traits/inconveniences.yml
@@ -26,3 +26,26 @@
fourRandomProb: 0
threeRandomProb: 0
cutRandomProb: 0
+
+- type: trait
+ id: ForeignerLight
+ category: Mental
+ points: 1
+ requirements:
+ - !type:TraitGroupExclusionRequirement
+ prototypes: [ Foreigner ]
+ components:
+ - type: ForeignerTrait
+ cantUnderstand: false # Allows to understand
+ baseTranslator: TranslatorForeigner
+
+- type: trait
+ id: Foreigner
+ category: Mental
+ points: 2
+ requirements: # TODO: Add a requirement to know at least 1 non-gc language
+ - !type:TraitGroupExclusionRequirement
+ prototypes: [ ForeignerLight ]
+ components:
+ - type: ForeignerTrait
+ baseTranslator: TranslatorForeigner