diff --git a/Content.Client/_CorvaxNext/CrewMedal/CrewMedalSystem.cs b/Content.Client/_CorvaxNext/CrewMedal/CrewMedalSystem.cs
new file mode 100644
index 00000000000..cb1b35c79bf
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CrewMedal/CrewMedalSystem.cs
@@ -0,0 +1,34 @@
+using Content.Client._CorvaxNext.CrewMedal.UI;
+using Content.Shared._CorvaxNext.CrewMedal;
+
+namespace Content.Client._CorvaxNext.CrewMedal;
+
+///
+/// Handles the client-side logic for the Crew Medal system.
+///
+public sealed class CrewMedalSystem : SharedCrewMedalSystem
+{
+ [Dependency] private readonly SharedUserInterfaceSystem _userInterfaceSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ // Subscribes to the event triggered after the state is automatically handled.
+ SubscribeLocalEvent(OnCrewMedalAfterState);
+ }
+
+ ///
+ /// When an updated state is received on the client, refresh the UI to display the latest data.
+ ///
+ private void OnCrewMedalAfterState(Entity entity, ref AfterAutoHandleStateEvent args)
+ {
+ // Checks if the Crew Medal UI is open for the given entity and reloads it with updated data.
+ if (_userInterfaceSystem.TryGetOpenUi(
+ entity.Owner,
+ CrewMedalUiKey.Key,
+ out var medalUi))
+ {
+ medalUi.Reload();
+ }
+ }
+}
diff --git a/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalBoundUserInterface.cs b/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalBoundUserInterface.cs
new file mode 100644
index 00000000000..30ef2141bb2
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalBoundUserInterface.cs
@@ -0,0 +1,65 @@
+using Content.Shared._CorvaxNext.CrewMedal;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._CorvaxNext.CrewMedal.UI;
+
+///
+/// A wrapper class for the Crew Medal user interface.
+/// Initializes the and updates it when new data is received from the server.
+///
+public sealed class CrewMedalBoundUserInterface : BoundUserInterface
+{
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+
+ ///
+ /// The main interface window.
+ ///
+ [ViewVariables]
+ private CrewMedalWindow? _window;
+
+ public CrewMedalBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ IoCManager.InjectDependencies(this);
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.OnReasonChanged += HandleReasonChanged;
+
+ Reload();
+ }
+
+ ///
+ /// Called when the reason is changed in the .
+ /// Sends a message to the server with the new reason if it differs from the current one.
+ ///
+ private void HandleReasonChanged(string newReason)
+ {
+ if (!_entityManager.TryGetComponent(Owner, out var component))
+ return;
+
+ if (!component.Reason.Equals(newReason))
+ {
+ SendPredictedMessage(new CrewMedalReasonChangedMessage(newReason));
+ }
+ }
+
+ ///
+ /// Updates the data in the window to reflect the current state of the .
+ ///
+ public void Reload()
+ {
+ if (_window is null)
+ return;
+
+ if (!_entityManager.TryGetComponent(Owner, out var component))
+ return;
+
+ _window.SetCurrentReason(component.Reason);
+ _window.SetAwarded(component.Awarded);
+ _window.SetMaxCharacters(component.MaxCharacters);
+ }
+}
diff --git a/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalWindow.xaml b/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalWindow.xaml
new file mode 100644
index 00000000000..95304f08e6f
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalWindow.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalWindow.xaml.cs b/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalWindow.xaml.cs
new file mode 100644
index 00000000000..1b804e6cbd8
--- /dev/null
+++ b/Content.Client/_CorvaxNext/CrewMedal/UI/CrewMedalWindow.xaml.cs
@@ -0,0 +1,88 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._CorvaxNext.CrewMedal.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class CrewMedalWindow : DefaultWindow
+{
+ ///
+ /// Event triggered when the "Save" button is pressed,
+ /// provided the user has changed the reason text.
+ ///
+ public event Action? OnReasonChanged;
+
+ private bool _isFocused;
+ private string _reason = string.Empty;
+ private bool _awarded;
+ private int _maxCharacters = 50;
+
+ public CrewMedalWindow()
+ {
+ RobustXamlLoader.Load(this);
+
+ ReasonLineEdit.OnTextChanged += _ =>
+ {
+ // Check character limit and award status
+ SaveButton.Disabled = _awarded || ReasonLineEdit.Text.Length > _maxCharacters;
+ CharacterLabel.Text = Loc.GetString(
+ "crew-medal-ui-character-limit",
+ ("number", ReasonLineEdit.Text.Length),
+ ("max", _maxCharacters));
+ };
+
+ ReasonLineEdit.OnFocusEnter += _ => _isFocused = true;
+ ReasonLineEdit.OnFocusExit += _ => _isFocused = false;
+
+ SaveButton.OnPressed += _ =>
+ {
+ OnReasonChanged?.Invoke(ReasonLineEdit.Text);
+ SaveButton.Disabled = true;
+ };
+
+ // Initialize the character counter display
+ CharacterLabel.Text = Loc.GetString(
+ "crew-medal-ui-character-limit",
+ ("number", ReasonLineEdit.Text.Length),
+ ("max", _maxCharacters));
+ }
+
+ ///
+ /// Sets the current reason and synchronizes it with the input field
+ /// if the user is not currently editing the field.
+ ///
+ public void SetCurrentReason(string reason)
+ {
+ if (_reason == reason)
+ return;
+
+ _reason = reason;
+
+ // Synchronize text if the input field is not focused
+ if (!_isFocused)
+ ReasonLineEdit.Text = _reason;
+ }
+
+ ///
+ /// Updates the "is medal awarded" status
+ /// and disables editing if the medal is already awarded.
+ ///
+ public void SetAwarded(bool awarded)
+ {
+ _awarded = awarded;
+ ReasonLineEdit.Editable = !_awarded;
+ SaveButton.Disabled = _awarded;
+ }
+
+ ///
+ /// Updates the maximum character limit for the reason.
+ /// If the current text exceeds the limit, it will be truncated.
+ ///
+ public void SetMaxCharacters(int number)
+ {
+ _maxCharacters = number;
+ if (ReasonLineEdit.Text.Length > _maxCharacters)
+ ReasonLineEdit.Text = ReasonLineEdit.Text[.._maxCharacters];
+ }
+}
diff --git a/Content.Server/_CorvaxNext/CrewMedal/CrewMedalSystem.cs b/Content.Server/_CorvaxNext/CrewMedal/CrewMedalSystem.cs
new file mode 100644
index 00000000000..1e4ce6cbe99
--- /dev/null
+++ b/Content.Server/_CorvaxNext/CrewMedal/CrewMedalSystem.cs
@@ -0,0 +1,137 @@
+using Content.Server.GameTicking;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Clothing;
+using Content.Shared._CorvaxNext.CrewMedal;
+using Content.Shared.Database;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Popups;
+using System.Linq;
+using System.Text;
+
+namespace Content.Server._CorvaxNext.CrewMedal;
+
+public sealed class CrewMedalSystem : SharedCrewMedalSystem
+{
+ [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnMedalEquipped);
+ SubscribeLocalEvent(OnMedalReasonChanged);
+ SubscribeLocalEvent(OnRoundEndText);
+ }
+
+ ///
+ /// Called when a medal is equipped on a character, indicating the medal has been awarded.
+ ///
+ private void OnMedalEquipped(Entity medal, ref ClothingGotEquippedEvent args)
+ {
+ if (medal.Comp.Awarded)
+ return;
+
+ medal.Comp.Recipient = Identity.Name(args.Wearer, EntityManager);
+ medal.Comp.Awarded = true;
+ Dirty(medal);
+
+ // Display a popup about the award
+ _popupSystem.PopupEntity(
+ Loc.GetString(
+ "comp-crew-medal-award-text",
+ ("recipient", medal.Comp.Recipient),
+ ("medal", Name(medal.Owner))
+ ),
+ medal.Owner
+ );
+
+ // Log the event
+ _adminLogger.Add(
+ LogType.Action,
+ LogImpact.Low,
+ $"{ToPrettyString(args.Wearer):player} was awarded the {ToPrettyString(medal.Owner):entity} with the reason \"{medal.Comp.Reason}\"."
+ );
+ }
+
+ ///
+ /// Called when the reason is updated in the interface (before the medal is awarded).
+ ///
+ private void OnMedalReasonChanged(EntityUid uid, CrewMedalComponent medalComp, CrewMedalReasonChangedMessage args)
+ {
+ if (medalComp.Awarded)
+ return;
+
+ // Trim to the character limit and sanitize the input
+ var maxLength = Math.Min(medalComp.MaxCharacters, args.Reason.Length);
+ medalComp.Reason = Sanitize(args.Reason[..maxLength]);
+
+ Dirty(uid, medalComp);
+
+ // Log the update
+ _adminLogger.Add(
+ LogType.Action,
+ LogImpact.Low,
+ $"{ToPrettyString(args.Actor):user} set {ToPrettyString(uid):entity} with award reason \"{medalComp.Reason}\"."
+ );
+ }
+
+ ///
+ /// Adds a list of awarded medals to the round-end summary window.
+ ///
+ private void OnRoundEndText(RoundEndTextAppendEvent ev)
+ {
+ var awardedMedals = new List<(string MedalName, string RecipientName, string Reason)>();
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var component))
+ {
+ if (component.Awarded)
+ {
+ awardedMedals.Add(
+ (Name(uid), component.Recipient, component.Reason)
+ );
+ }
+ }
+
+ if (awardedMedals.Count == 0)
+ return;
+
+ // Sort and convert to array
+ var sortedMedals = awardedMedals.OrderBy(x => x.RecipientName).ToArray();
+
+ var result = new StringBuilder();
+ result.AppendLine(
+ Loc.GetString(
+ "comp-crew-medal-round-end-result",
+ ("count", sortedMedals.Length)
+ )
+ );
+
+ foreach (var medal in sortedMedals)
+ {
+ result.AppendLine(
+ Loc.GetString(
+ "comp-crew-medal-round-end-list",
+ ("medal", Sanitize(medal.MedalName)),
+ ("recipient", Sanitize(medal.RecipientName)),
+ ("reason", Sanitize(medal.Reason))
+ )
+ );
+ }
+
+ ev.AddLine(result.AppendLine().ToString());
+ }
+
+ ///
+ /// Removes certain prohibited characters (e.g., brackets)
+ /// to prevent unwanted tags in the text.
+ ///
+ private string Sanitize(string input)
+ {
+ return input
+ .Replace("[", string.Empty)
+ .Replace("]", string.Empty)
+ .Replace("{", string.Empty)
+ .Replace("}", string.Empty);
+ }
+}
diff --git a/Content.Shared/_CorvaxNext/CrewMedal/CrewMedalComponent.cs b/Content.Shared/_CorvaxNext/CrewMedal/CrewMedalComponent.cs
new file mode 100644
index 00000000000..cd95ee512e3
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/CrewMedal/CrewMedalComponent.cs
@@ -0,0 +1,39 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._CorvaxNext.CrewMedal;
+
+///
+/// Component for a medal that can be awarded to a player and
+/// will be displayed in the final round summary screen.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class CrewMedalComponent : Component
+{
+ ///
+ /// The name of the recipient of the award.
+ ///
+ [AutoNetworkedField]
+ [DataField]
+ public string Recipient = string.Empty;
+
+ ///
+ /// The reason for the award. Can be set before the medal is awarded.
+ ///
+ [AutoNetworkedField]
+ [DataField]
+ public string Reason = string.Empty;
+
+ ///
+ /// If true, the medal is considered awarded, and the reason can no longer be changed.
+ ///
+ [AutoNetworkedField]
+ [DataField]
+ public bool Awarded;
+
+ ///
+ /// The maximum number of characters allowed for the reason.
+ ///
+ [AutoNetworkedField]
+ [DataField]
+ public int MaxCharacters = 50;
+}
diff --git a/Content.Shared/_CorvaxNext/CrewMedal/CrewMedalEvents.cs b/Content.Shared/_CorvaxNext/CrewMedal/CrewMedalEvents.cs
new file mode 100644
index 00000000000..b6345083f41
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/CrewMedal/CrewMedalEvents.cs
@@ -0,0 +1,24 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._CorvaxNext.CrewMedal;
+
+///
+/// Enum representing the key for the Crew Medal user interface.
+///
+[Serializable, NetSerializable]
+public enum CrewMedalUiKey : byte
+{
+ Key
+}
+
+///
+/// Message sent when the reason for the medal is changed via the user interface.
+///
+[Serializable, NetSerializable]
+public sealed class CrewMedalReasonChangedMessage(string Reason) : BoundUserInterfaceMessage
+{
+ ///
+ /// The new reason for the medal.
+ ///
+ public string Reason { get; } = Reason;
+}
diff --git a/Content.Shared/_CorvaxNext/CrewMedal/SharedCrewMedalSystem.cs b/Content.Shared/_CorvaxNext/CrewMedal/SharedCrewMedalSystem.cs
new file mode 100644
index 00000000000..35df39c2430
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/CrewMedal/SharedCrewMedalSystem.cs
@@ -0,0 +1,28 @@
+using Content.Shared.Examine;
+
+namespace Content.Shared._CorvaxNext.CrewMedal;
+
+public abstract class SharedCrewMedalSystem : EntitySystem
+{
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnExamined);
+ }
+
+ ///
+ /// Displays the reason and recipient of an awarded medal during an Examine action.
+ ///
+ private void OnExamined(Entity medal, ref ExaminedEvent args)
+ {
+ if (!medal.Comp.Awarded)
+ return;
+
+ var text = Loc.GetString(
+ "comp-crew-medal-inspection-text",
+ ("recipient", medal.Comp.Recipient),
+ ("reason", medal.Comp.Reason)
+ );
+
+ args.PushMarkup(text);
+ }
+}
diff --git a/Resources/Locale/ru-RU/_corvaxnext/crew-medals/crew-medal-component.ftl b/Resources/Locale/ru-RU/_corvaxnext/crew-medals/crew-medal-component.ftl
new file mode 100644
index 00000000000..b08f5f54e86
--- /dev/null
+++ b/Resources/Locale/ru-RU/_corvaxnext/crew-medals/crew-medal-component.ftl
@@ -0,0 +1,17 @@
+# interaction
+comp-crew-medal-inspection-text = Вручено {$recipient} за {$reason}.
+comp-crew-medal-award-text = {$recipient} награжден(а) {$medal}.
+# round end screen
+comp-crew-medal-round-end-result = {$count ->
+ [one] За этот раунд была вручена одна медаль:
+ *[other] За этот раунд было вручено {$count} медалей:
+}
+comp-crew-medal-round-end-list =
+ - [color=white]{$recipient}[/color] получил(а) [color=white]{$medal}[/color] за
+ {" "}{$reason}
+# UI
+crew-medal-ui-header = Настройка медали
+crew-medal-ui-reason = Причина награждения:
+crew-medal-ui-character-limit = {$number}/{$max}
+crew-medal-ui-info = После вручения медали причина награждения не может быть изменена.
+crew-medal-ui-save = Сохранить
diff --git a/Resources/Prototypes/Entities/Clothing/Neck/medals.yml b/Resources/Prototypes/Entities/Clothing/Neck/medals.yml
index 031fcb99881..724c2460a52 100644
--- a/Resources/Prototypes/Entities/Clothing/Neck/medals.yml
+++ b/Resources/Prototypes/Entities/Clothing/Neck/medals.yml
@@ -12,6 +12,16 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End
- type: entity
parent: ClothingNeckBase
@@ -28,6 +38,16 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End
- type: entity
parent: ClothingNeckBase
@@ -42,6 +62,16 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End
- type: entity
parent: ClothingNeckBase
@@ -56,6 +86,16 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End
- type: entity
parent: ClothingNeckBase
@@ -70,6 +110,16 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End
- type: entity
parent: ClothingNeckBase
@@ -84,6 +134,16 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End
- type: entity
parent: ClothingNeckBase
@@ -98,6 +158,16 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End
- type: entity
parent: ClothingNeckBase
@@ -114,3 +184,13 @@
- type: Tag
tags:
- Medal
+ # Corvax-Next-CrewMedals-Start
+ - type: CrewMedal
+ - type: ActivatableUI
+ key: enum.CrewMedalUiKey.Key
+ inHandsOnly: true
+ - type: UserInterface
+ interfaces:
+ enum.CrewMedalUiKey.Key:
+ type: CrewMedalBoundUserInterface
+ # Corvax-Next-CrewMedals-End