more traits improvements (#5285)

* wowie

* whoopsie

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* WHY DID I DO THAT

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Milon 2026-01-25 16:12:38 +01:00 committed by GitHub
parent df364a806a
commit 884cf115c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 503 additions and 43 deletions

View File

@ -565,6 +565,7 @@ namespace Content.Client.Lobby.UI
}
Traits.SetSelectedTraits(selectedTraits);
Traits.UpdateConditions(Profile.Species);
}
// End DeltaV - Traits Integration

View File

@ -0,0 +1,51 @@
using Content.Client._DV.Traits.UI;
using Content.Shared._DV.CCVars;
using Content.Shared._DV.Traits;
using Robust.Shared.Configuration;
using Robust.Shared.Prototypes;
namespace Content.Client._DV.Traits;
/// <summary>
/// Client system that shows a popup when traits are disabled due to unmet conditions.
/// </summary>
public sealed class DisabledTraitsPopupSystem : EntitySystem
{
[Dependency] private readonly IConfigurationManager _cfg = default!;
private DisabledTraitsPopup? _window;
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<DisabledTraitsEvent>(OnDisabledTraits);
}
private void OnDisabledTraits(DisabledTraitsEvent ev)
{
// Don't show if user has opted to skip this popup
if (_cfg.GetCVar(DCCVars.SkipDisabledTraitsPopup))
return;
// Don't show if no traits were actually disabled
if (ev.DisabledTraits.Count == 0)
return;
OpenDisabledTraitsPopup(ev.DisabledTraits);
}
private void OpenDisabledTraitsPopup(Dictionary<ProtoId<TraitPrototype>, List<string>> disabledTraits)
{
// Close existing window if one is open
if (_window != null)
{
_window.Close();
_window = null;
}
_window = new DisabledTraitsPopup(disabledTraits);
_window.OpenCentered();
_window.OnClose += () => _window = null;
}
}

View File

@ -0,0 +1,48 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
Title="{Loc disabled-traits-popup-title}"
MinSize="400 200"
SetSize="500 350">
<BoxContainer Orientation="Vertical" Margin="10">
<!-- Header -->
<BoxContainer Orientation="Horizontal" Margin="0 0 0 10">
<TextureRect TexturePath="/Textures/Interface/info.svg.192dpi.png"
TextureScale="0.5 0.5"
VerticalAlignment="Center"
Margin="0 0 10 0" />
<Label Name="TitleLabel"
StyleClasses="LabelHeading"
VAlign="Center" />
</BoxContainer>
<!-- Message -->
<RichTextLabel Name="MessageLabel"
Margin="0 0 0 15" />
<!-- List of disabled traits -->
<Label Text="{Loc disabled-traits-popup-list-header}"
StyleClasses="LabelSubText"
Margin="0 0 0 5" />
<PanelContainer StyleClasses="BackgroundDark" VerticalExpand="True">
<ScrollContainer HScrollEnabled="False" VerticalExpand="True">
<BoxContainer Name="DisabledTraitsContainer"
Orientation="Vertical"
Margin="5" />
</ScrollContainer>
</PanelContainer>
<!-- Footer with checkbox and close button -->
<BoxContainer Orientation="Vertical" Margin="0 10 0 0">
<CheckBox Name="SkipCheckBox"
Text="{Loc disabled-traits-popup-skip-checkbox}"
HorizontalAlignment="Center" />
<Button Name="ButtonClose"
Text="{Loc disabled-traits-popup-close-button}"
HorizontalAlignment="Center"
Margin="0 10 0 0"
MinSize="120 0" />
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View File

@ -0,0 +1,101 @@
using Content.Client.UserInterface.Controls;
using Content.Shared._DV.CCVars;
using Content.Shared._DV.Traits;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Configuration;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Client._DV.Traits.UI;
[GenerateTypedNameReferences]
public sealed partial class DisabledTraitsPopup : FancyWindow
{
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
private bool _initialSkipState;
public DisabledTraitsPopup(Dictionary<ProtoId<TraitPrototype>, List<string>> disabledTraits)
{
IoCManager.InjectDependencies(this);
RobustXamlLoader.Load(this);
InitializeUI(disabledTraits);
InitializeEvents();
}
private void InitializeUI(Dictionary<ProtoId<TraitPrototype>, List<string>> disabledTraits)
{
TitleLabel.Text = Loc.GetString("disabled-traits-popup-label");
MessageLabel.SetMessage(FormattedMessage.FromMarkupOrThrow(
Loc.GetString("disabled-traits-popup-message")));
_initialSkipState = _cfg.GetCVar(DCCVars.SkipDisabledTraitsPopup);
SkipCheckBox.Pressed = _initialSkipState;
PopulateDisabledTraits(disabledTraits);
}
private void PopulateDisabledTraits(Dictionary<ProtoId<TraitPrototype>, List<string>> disabledTraits)
{
DisabledTraitsContainer.RemoveAllChildren();
foreach (var (traitId, reasons) in disabledTraits)
{
if (!_prototype.TryIndex(traitId, out var trait))
continue;
var container = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Vertical,
Margin = new Thickness(0, 0, 0, 10)
};
// Trait name
var nameLabel = new Label
{
Text = Loc.GetString(trait.Name),
FontColorOverride = Color.FromHex("#ff6b6b"),
StyleClasses = { "LabelSubText" }
};
container.AddChild(nameLabel);
// Reasons why it was disabled
foreach (var reason in reasons)
{
var reasonLabel = new RichTextLabel
{
Margin = new Thickness(10, 2, 0, 0)
};
reasonLabel.SetMessage(FormattedMessage.FromMarkupOrThrow(
Loc.GetString("disabled-traits-popup-reason", ("reason", reason))));
container.AddChild(reasonLabel);
}
DisabledTraitsContainer.AddChild(container);
}
}
private void InitializeEvents()
{
OnClose += SaveSkipState;
ButtonClose.OnPressed += OnClosePressed;
}
private void SaveSkipState()
{
if (SkipCheckBox.Pressed == _initialSkipState)
return;
_cfg.SetCVar(DCCVars.SkipDisabledTraitsPopup, SkipCheckBox.Pressed);
_cfg.SaveToFile();
}
private void OnClosePressed(BaseButton.ButtonEventArgs args)
{
Close();
}
}

View File

@ -1,5 +1,7 @@
using System.Linq;
using Content.Shared._DV.Traits;
using Content.Shared.Humanoid.Prototypes;
using Content.Shared.Roles;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
@ -169,7 +171,7 @@ public sealed partial class TraitCategory : BoxContainer
/// Updates condition states for all trait entries based on current job/species.
/// Traits that don't meet conditions are disabled but still visible.
/// </summary>
public void UpdateConditions(string? jobId, string? speciesId)
public void UpdateConditions(ProtoId<JobPrototype>? jobId, ProtoId<SpeciesPrototype>? speciesId)
{
foreach (var (_, entry) in _traitEntries)
{

View File

@ -7,11 +7,27 @@
HorizontalExpand="True"
Margin="10 8">
<!-- Checkbox / Toggle -->
<!-- Toggle Container -->
<BoxContainer Name="ToggleContainer"
Orientation="Horizontal"
VerticalAlignment="Top"
Margin="0 2 10 0"
SetWidth="24">
<!-- Checkbox -->
<CheckBox Name="TraitCheckbox"
StyleClasses="TraitsEntryCheckbox"
VerticalAlignment="Top"
Margin="0 2 10 0"/>
VerticalAlignment="Center"/>
<!-- Lock Icon -->
<TextureRect Name="LockIcon"
VerticalAlignment="Center"
HorizontalAlignment="Center"
SetSize="20 20"
Stretch="KeepAspectCentered"
TexturePath="/Textures/Interface/VerbIcons/lock.svg.192dpi.png"
Visible="False"/>
</BoxContainer>
<!-- Trait Info -->
<BoxContainer Orientation="Vertical"
@ -31,7 +47,6 @@
<!-- Description -->
<RichTextLabel Name="TraitDescriptionLabel"
StyleClasses="TraitsEntryDescriptionLabel"
HorizontalExpand="True"
Margin="0 4 0 0"/>
</BoxContainer>
</BoxContainer>

View File

@ -1,5 +1,7 @@
using Content.Shared._DV.Traits;
using Content.Shared._DV.Traits.Conditions;
using Content.Shared.Humanoid.Prototypes;
using Content.Shared.Roles;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
@ -18,7 +20,8 @@ public sealed partial class TraitEntry : PanelContainer
public event Action<bool>? OnToggled;
public bool IsSelected => TraitCheckbox.Pressed;
public int TraitCost { get; }
public readonly int TraitCost;
public bool MeetsConditions { get; private set; } = true;
private readonly TraitPrototype _trait;
private bool _isUpdating;
@ -88,7 +91,7 @@ public sealed partial class TraitEntry : PanelContainer
/// <summary>
/// Updates whether conditions are met based on current job/species.
/// </summary>
public void UpdateConditionsMet(string? jobId, string? speciesId)
public void UpdateConditionsMet(ProtoId<JobPrototype>? jobId, ProtoId<SpeciesPrototype>? speciesId)
{
_failedConditionTooltips.Clear();
MeetsConditions = true;
@ -100,6 +103,8 @@ public sealed partial class TraitEntry : PanelContainer
IsSpeciesCondition speciesCond => CheckSpeciesCondition(speciesCond, speciesId),
HasJobCondition jobCond => CheckJobCondition(jobCond, jobId),
InDepartmentCondition deptCond => CheckDepartmentCondition(deptCond, jobId),
HasCompCondition compCond => !compCond.Invert, // can't check in lobby but screws with the inversion logic
AnyOfCondition anyOfCond => CheckAnyOfCondition(anyOfCond, jobId, speciesId),
_ => true,
};
@ -118,39 +123,71 @@ public sealed partial class TraitEntry : PanelContainer
UpdateDisabledState();
}
private bool CheckSpeciesCondition(IsSpeciesCondition condition, string? speciesId)
private bool CheckSpeciesCondition(IsSpeciesCondition condition, ProtoId<SpeciesPrototype>? speciesId)
{
if (string.IsNullOrEmpty(speciesId))
if (!_prototype.TryIndex(speciesId, out var species))
return false;
return speciesId == condition.Species.Id;
return species == condition.Species;
}
private bool CheckJobCondition(HasJobCondition condition, string? jobId)
private bool CheckJobCondition(HasJobCondition condition, ProtoId<JobPrototype>? jobId)
{
if (string.IsNullOrEmpty(jobId))
if (!_prototype.TryIndex(jobId, out var job))
return false;
return jobId == condition.Job;
return job == condition.Job;
}
private bool CheckDepartmentCondition(InDepartmentCondition condition, string? jobId)
private bool CheckDepartmentCondition(InDepartmentCondition condition, ProtoId<JobPrototype>? jobId)
{
if (string.IsNullOrEmpty(jobId))
if (!_prototype.TryIndex(jobId, out var job))
return false;
if (!_prototype.TryIndex(condition.Department, out var department))
return false;
return department.Roles.Contains(jobId);
return department.Roles.Contains(job);
}
private bool CheckAnyOfCondition(AnyOfCondition condition, ProtoId<JobPrototype>? jobId, ProtoId<SpeciesPrototype>? speciesId)
{
if (condition.Conditions.Count == 0)
return false;
// Return true if ANY child condition evaluates to true
foreach (var childCondition in condition.Conditions)
{
var result = childCondition switch
{
IsSpeciesCondition speciesCond => CheckSpeciesCondition(speciesCond, speciesId),
HasJobCondition jobCond => CheckJobCondition(jobCond, jobId),
InDepartmentCondition deptCond => CheckDepartmentCondition(deptCond, jobId),
HasCompCondition compCond => !compCond.Invert, // can't check in lobby
AnyOfCondition nestedAnyOf => CheckAnyOfCondition(nestedAnyOf, jobId, speciesId), // Recursive!
_ => true,
};
// Apply child's inversion
result ^= childCondition.Invert;
// If any child passes, the AnyOf passes
if (result)
return true;
}
// None of the children passed
return false;
}
private void UpdateDisabledState()
{
TraitCheckbox.Disabled = !MeetsConditions;
if (!MeetsConditions)
{
// Hide checkbox, show lock icon
TraitCheckbox.Visible = false;
LockIcon.Visible = true;
// Deselect if conditions no longer met
if (TraitCheckbox.Pressed)
{
@ -161,7 +198,7 @@ public sealed partial class TraitEntry : PanelContainer
OnToggled?.Invoke(false);
}
// Show why it's disabled
// Add disabled styling
AddStyleClass("TraitsEntryDisabled");
// Update tooltip to show failed conditions
@ -175,8 +212,15 @@ public sealed partial class TraitEntry : PanelContainer
}
else
{
// Show checkbox, hide lock icon
TraitCheckbox.Visible = true;
LockIcon.Visible = false;
// Remove disabled styling - stylesheet restores normal colors
RemoveStyleClass("TraitsEntryDisabled");
UpdateConditionTooltips(); // Reset to normal tooltips
// Reset to normal tooltips
UpdateConditionTooltips();
}
}
@ -187,7 +231,7 @@ public sealed partial class TraitEntry : PanelContainer
if (!MeetsConditions)
{
// Prevent selection if conditions not met
// This shouldn't happen since checkbox is hidden, but just in case
_isUpdating = true;
TraitCheckbox.Pressed = false;
_isUpdating = false;
@ -206,8 +250,6 @@ public sealed partial class TraitEntry : PanelContainer
_isUpdating = false;
}
public bool MeetsConditions { get; private set; } = true;
private void UpdateSelectedStyle()
{
if (TraitCheckbox.Pressed)

View File

@ -77,6 +77,14 @@ public sealed class TraitsSheetlet<T> : Sheetlet<T> where T : PalettedStylesheet
};
entrySelectedBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var entryDisabledBox = new StyleBoxFlat
{
BackgroundColor = Color.FromHex("#1a1a22"),
BorderColor = Color.FromHex("#2a2a2a"),
BorderThickness = new Thickness(1)
};
entryDisabledBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var progressBarBgBox = new StyleBoxFlat
{
BackgroundColor = bgDark,
@ -162,7 +170,7 @@ public sealed class TraitsSheetlet<T> : Sheetlet<T> where T : PalettedStylesheet
E<Button>()
.Class("TraitsCategoryHeaderButton")
.Prop(Button.StylePropertyStyleBox, categoryHeaderButtonBox),
.Prop(ContainerButton.StylePropertyStyleBox, categoryHeaderButtonBox),
E<Label>()
.Class("TraitsCategoryExpandIcon")
@ -200,10 +208,15 @@ public sealed class TraitsSheetlet<T> : Sheetlet<T> where T : PalettedStylesheet
.Panel(entryPanelBox),
E<PanelContainer>()
.Class("TraitsEntryPanel")
.Class("TraitsEntrySelected")
.Class("TraitsEntryPanel", "TraitsEntrySelected")
.Panel(entrySelectedBox),
// Disabled entry styling
E<PanelContainer>()
.Class("TraitsEntryPanel", "TraitsEntryDisabled")
.Panel(entryDisabledBox)
.Modulate(new Color(1f, 1f, 1f, 0.5f)),
E<Label>()
.Class("TraitsEntryNameLabel")
.Font(sheet.BaseFont.GetFont(11))

View File

@ -1,6 +1,7 @@
using System.Linq;
using Content.Shared._DV.CCVars;
using Content.Shared._DV.Traits;
using Content.Shared.Humanoid.Prototypes;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
@ -31,6 +32,8 @@ public sealed partial class TraitsTab : BoxContainer
private string _currentSearchText = string.Empty;
private bool _awaitingLayoutUpdate;
private ProtoId<SpeciesPrototype>? _currentSpeciesId;
public TraitsTab()
{
RobustXamlLoader.Load(this);
@ -261,8 +264,25 @@ public sealed partial class TraitsTab : BoxContainer
}
}
/// <summary>
/// Updates all trait conditions based on current job and species.
/// Call this when job or species changes in the character creator.
/// </summary>
/// <param name="speciesId">Current species ID, or null if none selected</param>
public void UpdateConditions(ProtoId<SpeciesPrototype> speciesId)
{
_currentSpeciesId = speciesId;
UpdateAllConditions();
}
private void UpdateAllConditions()
{
foreach (var (_, categoryUi) in _categoryUis)
{
// If some fork wants to use the top selected job as well, just add that to the UpdateConditions method in the editor
categoryUi.UpdateConditions(null, _currentSpeciesId);
}
RecalculateStats();
}

View File

@ -598,7 +598,7 @@ public sealed partial class TraitSystemTest
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
new object[] { player, selectedTraits, null, null, null, new Dictionary<ProtoId<TraitPrototype>, List<string>>() });
Assert.Multiple(() =>
{
@ -639,7 +639,7 @@ public sealed partial class TraitSystemTest
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
new object[] { player, selectedTraits, null, null, null, new Dictionary<ProtoId<TraitPrototype>, List<string>>()});
Assert.That(validTraits?.Count, Is.EqualTo(2), "Should respect category maxTraits limit");
@ -673,7 +673,7 @@ public sealed partial class TraitSystemTest
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
new object[] { player, selectedTraits, null, null, null, new Dictionary<ProtoId<TraitPrototype>, List<string>>() });
Assert.That(validTraits?.Count, Is.EqualTo(2), "Should respect category maxPoints limit");
@ -706,7 +706,7 @@ public sealed partial class TraitSystemTest
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
new object[] { player, selectedTraits, null, null, null, new Dictionary<ProtoId<TraitPrototype>, List<string>>() });
Assert.That(validTraits?.Contains("TestTraitHasComp"), Is.True, "Trait with met condition should be valid");
@ -739,7 +739,7 @@ public sealed partial class TraitSystemTest
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
new object[] { player, selectedTraits, null, null, null, new Dictionary<ProtoId<TraitPrototype>, List<string>>() });
Assert.That(validTraits?.Contains("TestTraitHasComp"),
Is.False,

View File

@ -50,8 +50,11 @@ public sealed class TraitSystem : EntitySystem
if (TryComp<HumanoidAppearanceComponent>(args.Mob, out var humanoid))
speciesId = humanoid.Species;
// Track disabled traits and reasons
var disabledTraits = new Dictionary<ProtoId<TraitPrototype>, List<string>>();
// Validate and collect valid traits
var validTraits = ValidateTraits(args.Mob, args.Profile.TraitPreferences, args.Player, args.JobId, speciesId);
var validTraits = ValidateTraits(args.Mob, args.Profile.TraitPreferences, args.Player, args.JobId, speciesId, disabledTraits);
// Apply valid traits
foreach (var traitId in validTraits)
@ -61,6 +64,12 @@ public sealed class TraitSystem : EntitySystem
ApplyTrait(args.Mob, trait);
}
// Send disabled traits notification to client if any were rejected
if (disabledTraits.Count > 0)
{
RaiseNetworkEvent(new DisabledTraitsEvent(disabledTraits), args.Player);
}
}
/// <summary>
@ -71,7 +80,8 @@ public sealed class TraitSystem : EntitySystem
IReadOnlySet<ProtoId<TraitPrototype>> selectedTraits,
ICommonSession? session,
string? jobId,
string? speciesId)
string? speciesId,
Dictionary<ProtoId<TraitPrototype>, List<string>> disabledTraits)
{
var validTraits = new HashSet<ProtoId<TraitPrototype>>();
var totalPoints = 0;
@ -100,25 +110,31 @@ public sealed class TraitSystem : EntitySystem
continue;
}
var rejectionReasons = new List<string>();
// Check global trait count limit
if (traitCount >= _maxTraitCount)
{
Log.Warning($"Trait {traitId} rejected: global trait count limit ({_maxTraitCount}) exceeded");
rejectionReasons.Add(Loc.GetString("disabled-traits-reason-global-limit"));
disabledTraits[traitId] = rejectionReasons;
continue;
}
// Check global points limit
if (totalPoints + trait.Cost > _maxTraitPoints)
{
Log.Warning(
$"Trait {traitId} rejected: global points limit ({_maxTraitPoints}) would be exceeded");
Log.Warning($"Trait {traitId} rejected: global points limit ({_maxTraitPoints}) would be exceeded");
rejectionReasons.Add(Loc.GetString("disabled-traits-reason-points-limit"));
disabledTraits[traitId] = rejectionReasons;
continue;
}
// Check category limits
if (!ValidateCategoryLimits(trait, categoryTraitCounts, categoryPointTotals))
if (!ValidateCategoryLimits(trait, categoryTraitCounts, categoryPointTotals, rejectionReasons))
{
Log.Warning($"Trait {traitId} rejected: category limits exceeded");
disabledTraits[traitId] = rejectionReasons;
continue;
}
@ -130,6 +146,11 @@ public sealed class TraitSystem : EntitySystem
if (trait.Conflicts.Contains(validTraitId))
{
Log.Warning($"Trait {traitId} rejected: conflicts with {validTraitId}");
if (_prototype.TryIndex(validTraitId, out var conflictTrait))
{
rejectionReasons.Add(Loc.GetString("disabled-traits-reason-conflict",
("trait", Loc.GetString(conflictTrait.Name))));
}
hasConflict = true;
break;
}
@ -139,18 +160,24 @@ public sealed class TraitSystem : EntitySystem
validTrait.Conflicts.Contains(traitId))
{
Log.Warning($"Trait {traitId} rejected: {validTraitId} conflicts with it");
rejectionReasons.Add(Loc.GetString("disabled-traits-reason-conflict",
("trait", Loc.GetString(validTrait.Name))));
hasConflict = true;
break;
}
}
if (hasConflict)
{
disabledTraits[traitId] = rejectionReasons;
continue;
}
// Check all conditions
if (!CheckConditions(trait, conditionCtx))
if (!CheckConditions(trait, conditionCtx, rejectionReasons))
{
Log.Warning($"Trait {traitId} rejected: conditions not met");
disabledTraits[traitId] = rejectionReasons;
continue;
}
@ -176,7 +203,8 @@ public sealed class TraitSystem : EntitySystem
private bool ValidateCategoryLimits(
TraitPrototype trait,
Dictionary<ProtoId<TraitCategoryPrototype>, int> categoryTraitCounts,
Dictionary<ProtoId<TraitCategoryPrototype>, int> categoryPointTotals)
Dictionary<ProtoId<TraitCategoryPrototype>, int> categoryPointTotals,
List<string> rejectionReasons)
{
if (!_prototype.TryIndex(trait.Category, out var category))
return true; // Unknown category, allow it
@ -186,11 +214,19 @@ public sealed class TraitSystem : EntitySystem
// Check category trait count limit
if (category.MaxTraits.HasValue && currentCount >= category.MaxTraits.Value)
{
rejectionReasons.Add(Loc.GetString("disabled-traits-reason-category-limit",
("category", Loc.GetString(category.Name))));
return false;
}
// Check category points limit
if (category.MaxPoints.HasValue && currentPoints + trait.Cost > category.MaxPoints.Value)
{
rejectionReasons.Add(Loc.GetString("disabled-traits-reason-category-points",
("category", Loc.GetString(category.Name))));
return false;
}
return true;
}
@ -198,11 +234,19 @@ public sealed class TraitSystem : EntitySystem
/// <summary>
/// Checks all conditions on a trait.
/// </summary>
private bool CheckConditions(TraitPrototype trait, TraitConditionContext ctx)
private bool CheckConditions(TraitPrototype trait, TraitConditionContext ctx, List<string> rejectionReasons)
{
foreach (var condition in trait.Conditions)
{
if (!condition.Evaluate(ctx))
if (condition.Evaluate(ctx))
continue;
// Get human-readable reason from the condition
var tooltip = condition.GetTooltip(ctx.Proto, Loc);
if (!string.IsNullOrEmpty(tooltip))
rejectionReasons.Add(tooltip);
return false;
}

View File

@ -141,6 +141,12 @@ public sealed partial class DCCVars
public static readonly CVarDef<int> MaxTraitPoints =
CVarDef.Create("traits.max_points", 15, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Whether to skip showing the disabled traits popup when spawning.
/// </summary>
public static readonly CVarDef<bool> SkipDisabledTraitsPopup =
CVarDef.Create("traits.skip_disabled_traits_popup", false, CVar.CLIENT | CVar.ARCHIVE);
/*
* Feedback webhook
*/

View File

@ -0,0 +1,64 @@
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Conditions;
/// <summary>
/// Condition that passes if ANY of the child conditions pass.
/// Use this to create "must meet at least one of these requirements" checks.
/// </summary>
public sealed partial class AnyOfCondition : BaseTraitCondition
{
/// <summary>
/// List of conditions to check. Passes if any condition evaluates to true.
/// </summary>
[DataField(required: true)]
public List<BaseTraitCondition> Conditions = new();
protected override bool EvaluateImplementation(TraitConditionContext ctx)
{
// Inversion doesn't make sense for AnyOfCondition - use inverted child conditions instead
if (Invert)
{
throw new InvalidOperationException(
"AnyOfCondition does not support Invert. To require none of the conditions, " +
"invert the individual child conditions instead.");
}
// Empty list should fail
if (Conditions.Count == 0)
return false;
// Return true if ANY condition passes
foreach (var condition in Conditions)
{
if (condition.Evaluate(ctx))
return true;
}
return false;
}
public override string GetTooltip(IPrototypeManager proto, ILocalizationManager loc)
{
if (Conditions.Count == 0)
return string.Empty;
var requirements = new List<string>();
foreach (var condition in Conditions)
{
var tooltip = condition.GetTooltip(proto, loc);
if (!string.IsNullOrEmpty(tooltip))
requirements.Add(tooltip);
}
if (requirements.Count == 0)
return string.Empty;
// AAAAAAAAAAA
var joinedRequirements = string.Join("\n• ", requirements);
// Handle inversion in tooltip (ANY becomes NONE when inverted)
return Loc.GetString("trait-condition-any-of", ("requirements", joinedRequirements));
}
}

View File

@ -15,6 +15,12 @@ public sealed partial class HasCompCondition : BaseTraitCondition
[DataField(required: true, customTypeSerializer: typeof(ComponentNameSerializer))]
public string Component = string.Empty;
/// <summary>
/// The tooltip text to display, if any.
/// </summary>
[DataField]
public LocId? Tooltip;
protected override bool EvaluateImplementation(TraitConditionContext ctx)
{
if (string.IsNullOrEmpty(Component))
@ -35,6 +41,10 @@ public sealed partial class HasCompCondition : BaseTraitCondition
public override string GetTooltip(IPrototypeManager proto, ILocalizationManager loc)
{
// If there's a custom tooltip supplied, use that
if (Tooltip is not null)
return Loc.GetString(Tooltip);
// No tooltip for this condition since we're dealing with comps
return string.Empty;
}

View File

@ -0,0 +1,17 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared._DV.Traits;
/// <summary>
/// Sent from server to client when a player spawns with traits that were disabled due to unmet conditions.
/// </summary>
[Serializable, NetSerializable]
public sealed class DisabledTraitsEvent(Dictionary<ProtoId<TraitPrototype>, List<string>> disabledTraits)
: EntityEventArgs
{
/// <summary>
/// Dictionary mapping disabled trait IDs to lists of reasons why they were disabled.
/// </summary>
public Dictionary<ProtoId<TraitPrototype>, List<string>> DisabledTraits = disabledTraits;
}

View File

@ -5,6 +5,22 @@ trait-editor-search-placeholder = Search traits...
trait-editor-footer-hint = Hover over traits for details
trait-editor-footer-info = Negative costs grant bonus points
## Disabled Traits Popup
disabled-traits-popup-title = Traits Disabled
disabled-traits-popup-label = Traits Disabled
disabled-traits-popup-message = Some of your selected traits could not be applied because they did not meet the required conditions.
disabled-traits-popup-list-header = The following traits were disabled:
disabled-traits-popup-reason = • {$reason}
disabled-traits-popup-skip-checkbox = Don't show this again
disabled-traits-popup-close-button = Close
## Disabled Traits Reasons
disabled-traits-reason-global-limit = Global trait limit reached
disabled-traits-reason-points-limit = Not enough trait points remaining
disabled-traits-reason-category-limit = Category "{$category}" trait limit reached
disabled-traits-reason-category-points = Category "{$category}" points limit reached
disabled-traits-reason-conflict = Conflicts with selected trait: {$trait}
## Category suffixes
trait-category-traits = {$selected} / {$max} traits
trait-category-traits-unlimited = {$selected} traits
@ -16,6 +32,10 @@ trait-conditions-tooltip = [bold]Requirements:[/bold]
trait-conditions-not-met-tooltip = Requirements not met:
{$requirements}
## Composite conditions
trait-condition-any-of = Any of the following must be true:
• {$requirements}
## Species conditions
trait-condition-species-is = You must be a [color=yellow]{$species}[/color].
trait-condition-species-not = You must not be a [color=yellow]{$species}[/color].
@ -27,3 +47,6 @@ trait-condition-job-not = You must not be a [color={$color}]{$job}[/color].
## Department conditions
trait-condition-department-is = You must be in the [color={$color}]{$department}[/color] department.
trait-condition-department-not = You must not be in the [color={$color}]{$department}[/color] department.
## HasComp borg conditions
trait-condition-borg-not = You must not be a [color=yellow]borg[/color].

View File

@ -97,6 +97,7 @@
- !type:HasCompCondition
component: BorgChassis
invert: true
tooltip: trait-condition-borg-not
- !type:IsSpeciesCondition
species: IPC
invert: true

View File

@ -56,6 +56,7 @@
- !type:HasCompCondition
component: BorgChassis
invert: true
tooltip: trait-condition-borg-not
effects:
- !type:AddCompsEffect
components:
@ -168,6 +169,7 @@
- !type:HasCompCondition
component: BorgChassis
invert: true
tooltip: trait-condition-borg-not
effects:
- !type:AddCompsEffect
components: