real traits system (#5208)

* holy shit?

* final touches

* this is dumb

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

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

* whatever go my integration tests

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

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

* whoopsie forgot blacklists

* load bearing loc

* minor stuff

* whoopsie

* cool

* I LOVE REFLECTION

* got a call from the stink department

* i love fluent yes

* direction changes

* waiter more migrations please

* typo ops

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Tobias Berger <toby@tobot.dev>
This commit is contained in:
Milon 2026-01-20 12:49:59 +01:00 committed by GitHub
parent f05e2613df
commit 4364eaa388
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 8355 additions and 711 deletions

View File

@ -123,10 +123,10 @@ public sealed class LobbyUIController : UIController, IOnStateEntered<LobbyState
_profileEditor.RefreshSpecies(); _profileEditor.RefreshSpecies();
} }
if (obj.WasModified<TraitPrototype>()) // if (obj.WasModified<TraitPrototype>()) // DeltaV - Refreshed in TraitsTab
{ // {
_profileEditor.RefreshTraits(); // _profileEditor.RefreshTraits();
} // }
} }
} }

View File

@ -3,6 +3,7 @@
xmlns:humanoid="clr-namespace:Content.Client.Humanoid" xmlns:humanoid="clr-namespace:Content.Client.Humanoid"
xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls" xmlns:cc="clr-namespace:Content.Client.Administration.UI.CustomControls"
xmlns:ui="clr-namespace:Content.Client.Lobby.UI" xmlns:ui="clr-namespace:Content.Client.Lobby.UI"
xmlns:traits="clr-namespace:Content.Client._DV.Traits.UI"
HorizontalExpand="True"> HorizontalExpand="True">
<!-- Left side --> <!-- Left side -->
<BoxContainer Orientation="Vertical" Margin="10 10 10 10" HorizontalExpand="True"> <BoxContainer Orientation="Vertical" Margin="10 10 10 10" HorizontalExpand="True">
@ -133,12 +134,15 @@
<BoxContainer Name="AntagList" Orientation="Vertical" /> <BoxContainer Name="AntagList" Orientation="Vertical" />
</ScrollContainer> </ScrollContainer>
</BoxContainer> </BoxContainer>
<BoxContainer Orientation="Vertical" Margin="10"> <!-- DV - Traits -->
<!-- Traits --> <!-- <BoxContainer Orientation="Vertical" Margin="10"> -->
<ScrollContainer VerticalExpand="True"> <!-- ~1~ Traits @1@ -->
<BoxContainer Name="TraitsList" Orientation="Vertical" /> <!-- <ScrollContainer VerticalExpand="True"> -->
</ScrollContainer> <!-- <BoxContainer Name="TraitsList" Orientation="Vertical" /> -->
</BoxContainer> <!-- </ScrollContainer> -->
<!-- </BoxContainer> -->
<traits:TraitsTab Name="Traits"/>
<!-- End DV -->
<BoxContainer Name="MarkingsTab" Orientation="Vertical" Margin="10"> <BoxContainer Name="MarkingsTab" Orientation="Vertical" Margin="10">
<!-- Markings --> <!-- Markings -->
<ScrollContainer VerticalExpand="True"> <ScrollContainer VerticalExpand="True">

View File

@ -39,6 +39,7 @@ using System.Globalization;
using Content.Client._CD.Records.UI; using Content.Client._CD.Records.UI;
using Content.Shared._CD.Records; using Content.Shared._CD.Records;
// End CD - Character Records // End CD - Character Records
using Content.Shared._DV.Traits; // DV - Traits
namespace Content.Client.Lobby.UI namespace Content.Client.Lobby.UI
{ {
@ -180,6 +181,8 @@ namespace Content.Client.Lobby.UI
Save?.Invoke(); Save?.Invoke();
}; };
Traits.OnTraitsChanged += OnTraitsSelectionChanged; // DeltaV
#region Left #region Left
#region Name #region Name
@ -239,7 +242,6 @@ namespace Content.Client.Lobby.UI
{ {
SpeciesButton.SelectId(args.Id); SpeciesButton.SelectId(args.Id);
SetSpecies(_species[args.Id].ID); SetSpecies(_species[args.Id].ID);
RefreshTraits(); // DeltaV - Allows for hiding traits
UpdateHairPickers(); UpdateHairPickers();
OnSkinColorOnValueChanged(); OnSkinColorOnValueChanged();
}; };
@ -462,7 +464,7 @@ namespace Content.Client.Lobby.UI
TabContainer.SetTabTitle(2, Loc.GetString("humanoid-profile-editor-antags-tab")); TabContainer.SetTabTitle(2, Loc.GetString("humanoid-profile-editor-antags-tab"));
RefreshTraits(); // RefreshTraits(); // DeltaV
#region Markings #region Markings
@ -515,6 +517,57 @@ namespace Content.Client.Lobby.UI
IsDirty = false; IsDirty = false;
} }
// Begin DeltaV - Traits Integration
/// <summary>
/// Called when trait selection changes in the TraitsTab.
/// Updates the profile with the new trait selection.
/// </summary>
private void OnTraitsSelectionChanged(HashSet<ProtoId<TraitPrototype>> traits)
{
if (Profile is null)
return;
// Remove all existing traits - iterate directly over readonly collection
foreach (var existingTrait in Profile.TraitPreferences)
{
Profile = Profile.WithoutTraitPreference(existingTrait, _prototypeManager);
}
// Add newly selected traits
foreach (var trait in traits)
{
Profile = Profile.WithTraitPreference(trait.Id, _prototypeManager);
}
SetDirty();
}
/// <summary>
/// Updates the traits tab with the current profile's selected traits.
/// </summary>
private void UpdateTraitsSelection()
{
if (Profile is null)
{
Traits.SetSelectedTraits(new HashSet<ProtoId<TraitPrototype>>());
return;
}
// Convert profile's trait preferences (strings) to ProtoId<TraitPrototype>
var selectedTraits = new HashSet<ProtoId<TraitPrototype>>(Profile.TraitPreferences.Count);
foreach (var traitId in Profile.TraitPreferences)
{
// Validate that the trait still exists in prototypes
if (_prototypeManager.HasIndex(traitId))
{
selectedTraits.Add(new ProtoId<TraitPrototype>(traitId));
}
}
Traits.SetSelectedTraits(selectedTraits);
}
// End DeltaV - Traits Integration
/// <summary> /// <summary>
/// Refreshes the flavor text editor status. /// Refreshes the flavor text editor status.
/// </summary> /// </summary>
@ -549,122 +602,122 @@ namespace Content.Client.Lobby.UI
/// <summary> /// <summary>
/// Refreshes traits selector /// Refreshes traits selector
/// </summary> /// </summary>
public void RefreshTraits() // public void RefreshTraits()
{ // {
TraitsList.RemoveAllChildren(); // TraitsList.RemoveAllChildren();
//
var traits = _prototypeManager.EnumeratePrototypes<TraitPrototype>().OrderBy(t => Loc.GetString(t.Name)).ToList(); // var traits = _prototypeManager.EnumeratePrototypes<TraitPrototype>().OrderBy(t => Loc.GetString(t.Name)).ToList();
TabContainer.SetTabTitle(3, Loc.GetString("humanoid-profile-editor-traits-tab")); // TabContainer.SetTabTitle(3, Loc.GetString("humanoid-profile-editor-traits-tab"));
//
if (traits.Count < 1) // if (traits.Count < 1)
{ // {
TraitsList.AddChild(new Label // TraitsList.AddChild(new Label
{ // {
Text = Loc.GetString("humanoid-profile-editor-no-traits"), // Text = Loc.GetString("humanoid-profile-editor-no-traits"),
FontColorOverride = Color.Gray, // FontColorOverride = Color.Gray,
}); // });
return; // return;
} // }
//
// Setup model // // Setup model
Dictionary<string, List<string>> traitGroups = new(); // Dictionary<string, List<string>> traitGroups = new();
List<string> defaultTraits = new(); // List<string> defaultTraits = new();
traitGroups.Add(TraitCategoryPrototype.Default, defaultTraits); // traitGroups.Add(TraitCategoryPrototype.Default, defaultTraits);
//
foreach (var trait in traits) // foreach (var trait in traits)
{ // {
// Begin DeltaV Additions - Species trait exclusion // // Begin DeltaV Additions - Species trait exclusion
if (Profile?.Species is { } selectedSpecies && trait.ExcludedSpecies.Contains(selectedSpecies)) // if (Profile?.Species is { } selectedSpecies && trait.ExcludedSpecies.Contains(selectedSpecies))
{ // {
Profile = Profile?.WithoutTraitPreference(trait.ID, _prototypeManager); // Profile = Profile?.WithoutTraitPreference(trait.ID, _prototypeManager);
continue; // continue;
} // }
// End DeltaV Additions // // End DeltaV Additions
//
if (trait.Category == null) // if (trait.Category == null)
{ // {
defaultTraits.Add(trait.ID); // defaultTraits.Add(trait.ID);
continue; // continue;
} // }
//
if (!_prototypeManager.HasIndex(trait.Category)) // if (!_prototypeManager.HasIndex(trait.Category))
continue; // continue;
//
var group = traitGroups.GetOrNew(trait.Category); // var group = traitGroups.GetOrNew(trait.Category);
group.Add(trait.ID); // group.Add(trait.ID);
} // }
//
// Create UI view from model // // Create UI view from model
foreach (var (categoryId, categoryTraits) in traitGroups) // foreach (var (categoryId, categoryTraits) in traitGroups)
{ // {
TraitCategoryPrototype? category = null; // TraitCategoryPrototype? category = null;
//
if (categoryId != TraitCategoryPrototype.Default) // if (categoryId != TraitCategoryPrototype.Default)
{ // {
category = _prototypeManager.Index<TraitCategoryPrototype>(categoryId); // category = _prototypeManager.Index<TraitCategoryPrototype>(categoryId);
// Label // // Label
TraitsList.AddChild(new Label // TraitsList.AddChild(new Label
{ // {
Text = Loc.GetString(category.Name), // Text = Loc.GetString(category.Name),
Margin = new Thickness(0, 10, 0, 0), // Margin = new Thickness(0, 10, 0, 0),
StyleClasses = { StyleClass.LabelHeading }, // StyleClasses = { StyleClass.LabelHeading },
}); // });
} // }
//
List<TraitPreferenceSelector?> selectors = new(); // List<TraitPreferenceSelector?> selectors = new();
var selectionCount = 0; // var selectionCount = 0;
//
foreach (var traitProto in categoryTraits) // foreach (var traitProto in categoryTraits)
{ // {
var trait = _prototypeManager.Index<TraitPrototype>(traitProto); // var trait = _prototypeManager.Index<TraitPrototype>(traitProto);
var selector = new TraitPreferenceSelector(trait); // var selector = new TraitPreferenceSelector(trait);
//
selector.Preference = Profile?.TraitPreferences.Contains(trait.ID) == true; // selector.Preference = Profile?.TraitPreferences.Contains(trait.ID) == true;
if (selector.Preference) // if (selector.Preference)
selectionCount += trait.Cost; // selectionCount += trait.Cost;
//
selector.PreferenceChanged += preference => // selector.PreferenceChanged += preference =>
{ // {
if (preference) // if (preference)
{ // {
Profile = Profile?.WithTraitPreference(trait.ID, _prototypeManager); // Profile = Profile?.WithTraitPreference(trait.ID, _prototypeManager);
} // }
else // else
{ // {
Profile = Profile?.WithoutTraitPreference(trait.ID, _prototypeManager); // Profile = Profile?.WithoutTraitPreference(trait.ID, _prototypeManager);
} // }
//
SetDirty(); // SetDirty();
RefreshTraits(); // If too many traits are selected, they will be reset to the real value. // RefreshTraits(); // If too many traits are selected, they will be reset to the real value.
}; // };
selectors.Add(selector); // selectors.Add(selector);
} // }
//
// Selection counter // // Selection counter
if (category is { MaxTraitPoints: >= 0 }) // if (category is { MaxTraitPoints: >= 0 })
{ // {
TraitsList.AddChild(new Label // TraitsList.AddChild(new Label
{ // {
Text = Loc.GetString("humanoid-profile-editor-trait-count-hint", ("current", selectionCount) ,("max", category.MaxTraitPoints)), // Text = Loc.GetString("humanoid-profile-editor-trait-count-hint", ("current", selectionCount) ,("max", category.MaxTraitPoints)),
FontColorOverride = Color.Gray // FontColorOverride = Color.Gray
}); // });
} // }
//
foreach (var selector in selectors) // foreach (var selector in selectors)
{ // {
if (selector == null) // if (selector == null)
continue; // continue;
//
if (category is { MaxTraitPoints: >= 0 } && // if (category is { MaxTraitPoints: >= 0 } &&
selector.Cost + selectionCount > category.MaxTraitPoints) // selector.Cost + selectionCount > category.MaxTraitPoints)
{ // {
selector.Checkbox.Label.FontColorOverride = Color.Red; // selector.Checkbox.Label.FontColorOverride = Color.Red;
} // }
//
TraitsList.AddChild(selector); // TraitsList.AddChild(selector);
} // }
} // }
} // }
/// <summary> /// <summary>
/// Refreshes the species selector. /// Refreshes the species selector.
@ -845,11 +898,13 @@ namespace Content.Client.Lobby.UI
_recordsTab.Update(profile); _recordsTab.Update(profile);
// End CD - Character Records // End CD - Character Records
UpdateTraitsSelection(); // DeltaV - Traits
RefreshAntags(); RefreshAntags();
RefreshJobs(); RefreshJobs();
RefreshLoadouts(); RefreshLoadouts();
RefreshSpecies(); RefreshSpecies();
RefreshTraits(); // RefreshTraits(); // DeltaV
RefreshFlavorText(); RefreshFlavorText();
ReloadPreview(); ReloadPreview();

View File

@ -19,22 +19,24 @@ public sealed partial class TraitPreferenceSelector : Control
public event Action<bool>? PreferenceChanged; public event Action<bool>? PreferenceChanged;
public TraitPreferenceSelector(TraitPrototype trait) // DeltaV - This whole control is generally unused but the compiler wouldn't compile if I removed it
{ // So I'm just gonna comment this part out
RobustXamlLoader.Load(this); // public TraitPreferenceSelector(TraitPrototype trait)
// {
var text = trait.Cost != 0 ? $"[{trait.Cost}] " : ""; // RobustXamlLoader.Load(this);
text += Loc.GetString(trait.Name); //
// var text = trait.Cost != 0 ? $"[{trait.Cost}] " : "";
Cost = trait.Cost; // text += Loc.GetString(trait.Name);
Checkbox.Text = text; //
Checkbox.OnToggled += OnCheckBoxToggled; // Cost = trait.Cost;
// Checkbox.Text = text;
if (trait.Description is { } desc) // Checkbox.OnToggled += OnCheckBoxToggled;
{ //
Checkbox.ToolTip = Loc.GetString(desc); // if (trait.Description is { } desc)
} // {
} // Checkbox.ToolTip = Loc.GetString(desc);
// }
// }
private void OnCheckBoxToggled(BaseButton.ButtonToggledEventArgs args) private void OnCheckBoxToggled(BaseButton.ButtonToggledEventArgs args)
{ {

View File

@ -0,0 +1,50 @@
<BoxContainer xmlns="https://spacestation14.io"
Orientation="Vertical"
HorizontalExpand="True"
Margin="0 0 0 8">
<!-- Category Header -->
<PanelContainer Name="HeaderPanel" StyleClasses="TraitsCategoryHeader">
<Button Name="HeaderButton"
StyleClasses="TraitsCategoryHeaderButton"
HorizontalExpand="True">
<BoxContainer Orientation="Horizontal" Margin="8 6">
<!-- Expand/Collapse Icon -->
<Label Name="ExpandIcon"
Text="▼"
StyleClasses="TraitsCategoryExpandIcon"
Margin="0 0 8 0"/>
<!-- Category Name -->
<Label Name="CategoryNameLabel"
StyleClasses="TraitsCategoryNameLabel"/>
<Control HorizontalExpand="True"/>
<!-- Category Stats -->
<Label Name="CategoryStatsLabel"
StyleClasses="TraitsCategoryStatsLabel"
Margin="0 0 4 0"/>
<!-- Category Points (if applicable) -->
<Label Name="CategoryPointsLabel"
StyleClasses="TraitsCategoryPointsLabel"
Visible="False"/>
</BoxContainer>
</Button>
</PanelContainer>
<!-- Accent Bar -->
<PanelContainer Name="AccentBar"
StyleClasses="TraitsCategoryAccent"
SetHeight="3"
Margin="0 0 0 0"/>
<!-- Traits Container -->
<PanelContainer Name="ContentPanel" StyleClasses="TraitsCategoryContent">
<BoxContainer Name="TraitsContainer"
Orientation="Vertical"
HorizontalExpand="True"
Margin="8 8"/>
</PanelContainer>
</BoxContainer>

View File

@ -0,0 +1,190 @@
using System.Linq;
using Content.Shared._DV.Traits;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
namespace Content.Client._DV.Traits.UI;
[GenerateTypedNameReferences]
public sealed partial class TraitCategory : BoxContainer
{
public event Action<ProtoId<TraitPrototype>, bool>? OnTraitToggled;
private readonly TraitCategoryPrototype _category;
private readonly List<TraitPrototype> _allTraits;
private readonly Dictionary<ProtoId<TraitPrototype>, TraitEntry> _traitEntries = new();
private bool _isExpanded;
public int SelectedCount;
public int PointsSpent;
public TraitCategory(TraitCategoryPrototype category, List<TraitPrototype> traits)
{
RobustXamlLoader.Load(this);
_category = category;
_allTraits = traits;
_isExpanded = category.DefaultExpanded;
CategoryNameLabel.Text = Loc.GetString(category.Name);
SetAccentColor(category.AccentColor);
HeaderButton.OnPressed += _ => ToggleExpanded();
PopulateTraits();
UpdateExpandedState();
UpdateStats();
}
private void SetAccentColor(Color color)
{
AccentBar.PanelOverride = new StyleBoxFlat { BackgroundColor = color }; // dumb stylesheet modulation workaround
}
private void PopulateTraits()
{
TraitsContainer.RemoveAllChildren();
_traitEntries.Clear();
foreach (var trait in _allTraits)
{
var entry = new TraitEntry(trait);
entry.OnToggled += selected => OnTraitEntryToggled(trait.ID, selected);
_traitEntries[trait.ID] = entry;
TraitsContainer.AddChild(entry);
}
}
private void OnTraitEntryToggled(ProtoId<TraitPrototype> traitId, bool selected)
{
OnTraitToggled?.Invoke(traitId, selected);
}
private void ToggleExpanded()
{
_isExpanded = !_isExpanded;
UpdateExpandedState();
}
private void UpdateExpandedState()
{
ContentPanel.Visible = _isExpanded;
ExpandIcon.Text = _isExpanded ? "▼" : "▶";
}
public void UpdateStats()
{
SelectedCount = _traitEntries.Values.Count(e => e.IsSelected);
PointsSpent = _traitEntries.Values
.Where(e => e.IsSelected)
.Sum(e => e.TraitCost);
if (_category.MaxTraits.HasValue)
{
CategoryStatsLabel.Text = Loc.GetString("trait-category-traits",
("selected", SelectedCount),
("max", _category.MaxTraits.Value));
}
else
{
CategoryStatsLabel.Text = Loc.GetString("trait-category-traits-unlimited",
("selected", SelectedCount));
}
if (_category.MaxPoints.HasValue)
{
CategoryPointsLabel.Visible = true;
CategoryPointsLabel.Text = Loc.GetString("trait-category-points",
("selected", PointsSpent),
("max", _category.MaxPoints.Value));
}
else
{
CategoryPointsLabel.Visible = false;
}
}
public void SetTraitSelected(ProtoId<TraitPrototype> traitId, bool selected)
{
if (_traitEntries.TryGetValue(traitId, out var entry))
{
entry.SetSelected(selected);
}
}
public void ClearSelection()
{
foreach (var (_, entry) in _traitEntries)
{
entry.SetSelected(false);
}
SelectedCount = 0;
PointsSpent = 0;
UpdateStats();
}
/// <summary>
/// Gets the IDs of all currently selected traits in this category.
/// </summary>
public IEnumerable<ProtoId<TraitPrototype>> GetSelectedTraitIds()
{
return _traitEntries
.Where(kvp => kvp.Value.IsSelected)
.Select(kvp => kvp.Key);
}
/// <summary>
/// Filters traits based on search text only
/// </summary>
public void FilterTraits(string searchText)
{
var hasVisibleTraits = false;
foreach (var (traitId, entry) in _traitEntries)
{
var trait = _allTraits.First(t => t.ID == traitId);
var name = Loc.GetString(trait.Name);
var description = Loc.GetString(trait.Description);
var matchesSearch = string.IsNullOrEmpty(searchText) ||
name.Contains(searchText, StringComparison.OrdinalIgnoreCase) ||
description.Contains(searchText, StringComparison.OrdinalIgnoreCase);
entry.Visible = matchesSearch;
if (entry.Visible)
hasVisibleTraits = true;
}
// Hide entire category if no traits match search
Visible = hasVisibleTraits;
}
/// <summary>
/// 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)
{
foreach (var (_, entry) in _traitEntries)
{
entry.UpdateConditionsMet(jobId, speciesId);
}
// Update stats since some traits may have been deselected
UpdateStats();
}
/// <summary>
/// Checks if a trait in this category meets its conditions.
/// </summary>
public bool TraitMeetsConditions(ProtoId<TraitPrototype> traitId)
{
return _traitEntries.TryGetValue(traitId, out var entry) && entry.MeetsConditions;
}
}

View File

@ -0,0 +1,38 @@
<PanelContainer xmlns="https://spacestation14.io"
StyleClasses="TraitsEntryPanel"
HorizontalExpand="True"
Margin="0 0 0 4">
<BoxContainer Orientation="Horizontal"
HorizontalExpand="True"
Margin="10 8">
<!-- Checkbox / Toggle -->
<CheckBox Name="TraitCheckbox"
StyleClasses="TraitsEntryCheckbox"
VerticalAlignment="Top"
Margin="0 2 10 0"/>
<!-- Trait Info -->
<BoxContainer Orientation="Vertical"
HorizontalExpand="True"
VerticalAlignment="Center"
Name="TraitInfo">
<!-- Name and Cost Row -->
<BoxContainer Orientation="Horizontal">
<Label Name="TraitNameLabel"
StyleClasses="TraitsEntryNameLabel"/>
<Control HorizontalExpand="True"/>
<Label Name="TraitCostLabel"
StyleClasses="TraitsEntryCostLabel"/>
</BoxContainer>
<!-- Description -->
<RichTextLabel Name="TraitDescriptionLabel"
StyleClasses="TraitsEntryDescriptionLabel"
HorizontalExpand="True"
Margin="0 4 0 0"/>
</BoxContainer>
</BoxContainer>
</PanelContainer>

View File

@ -0,0 +1,218 @@
using Content.Shared._DV.Traits;
using Content.Shared._DV.Traits.Conditions;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Client._DV.Traits.UI;
[GenerateTypedNameReferences]
public sealed partial class TraitEntry : PanelContainer
{
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly ILocalizationManager _loc = default!;
public event Action<bool>? OnToggled;
public bool IsSelected => TraitCheckbox.Pressed;
public int TraitCost { get; }
private readonly TraitPrototype _trait;
private bool _isUpdating;
private readonly List<string> _failedConditionTooltips = new();
public TraitEntry(TraitPrototype trait)
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_trait = trait;
TraitCost = trait.Cost;
// Enable mouse events so tooltips work
MouseFilter = MouseFilterMode.Pass;
TraitNameLabel.Text = Loc.GetString(trait.Name);
TraitDescriptionLabel.SetMessage(Loc.GetString(trait.Description));
// Format cost display
var costPrefix = trait.Cost > 0 ? "+" : "";
var costColor = trait.Cost > 0 ? "#ff6b6b" : trait.Cost < 0 ? "#6bff6b" : "#888888";
TraitCostLabel.Text = $"{costPrefix}{trait.Cost}";
TraitCostLabel.ModulateSelfOverride = Color.FromHex(costColor);
TraitCheckbox.OnToggled += OnCheckboxToggled;
// Build condition tooltips
UpdateConditionTooltips();
}
private void UpdateConditionTooltips()
{
var tooltips = new List<string>();
foreach (var condition in _trait.Conditions)
{
var tooltip = condition.GetTooltip(_prototype, _loc);
if (!string.IsNullOrEmpty(tooltip))
tooltips.Add(tooltip);
}
if (tooltips.Count > 0)
{
var tooltipText = Loc.GetString("trait-conditions-tooltip",
("requirements", string.Join("\n", tooltips)));
TooltipSupplier = _ => CreateMarkupTooltip(tooltipText);
}
else
TooltipSupplier = null;
}
/// <summary>
/// Creates a tooltip control that properly parses markup.
/// </summary>
private static Tooltip CreateMarkupTooltip(string markupText)
{
var tooltip = new Tooltip();
// Parse the markup into a FormattedMessage
tooltip.SetMessage(FormattedMessage.FromMarkupOrThrow(markupText));
return tooltip;
}
/// <summary>
/// Updates whether conditions are met based on current job/species.
/// </summary>
public void UpdateConditionsMet(string? jobId, string? speciesId)
{
_failedConditionTooltips.Clear();
MeetsConditions = true;
foreach (var condition in _trait.Conditions)
{
var result = condition switch
{
IsSpeciesCondition speciesCond => CheckSpeciesCondition(speciesCond, speciesId),
HasJobCondition jobCond => CheckJobCondition(jobCond, jobId),
InDepartmentCondition deptCond => CheckDepartmentCondition(deptCond, jobId),
_ => true,
};
// Apply inversion
result ^= condition.Invert;
if (!result)
{
MeetsConditions = false;
var tooltip = condition.GetTooltip(_prototype, _loc);
if (!string.IsNullOrEmpty(tooltip))
_failedConditionTooltips.Add(tooltip);
}
}
UpdateDisabledState();
}
private bool CheckSpeciesCondition(IsSpeciesCondition condition, string? speciesId)
{
if (string.IsNullOrEmpty(speciesId))
return false;
return speciesId == condition.Species.Id;
}
private bool CheckJobCondition(HasJobCondition condition, string? jobId)
{
if (string.IsNullOrEmpty(jobId))
return false;
return jobId == condition.Job;
}
private bool CheckDepartmentCondition(InDepartmentCondition condition, string? jobId)
{
if (string.IsNullOrEmpty(jobId))
return false;
if (!_prototype.TryIndex(condition.Department, out var department))
return false;
return department.Roles.Contains(jobId);
}
private void UpdateDisabledState()
{
TraitCheckbox.Disabled = !MeetsConditions;
if (!MeetsConditions)
{
// Deselect if conditions no longer met
if (TraitCheckbox.Pressed)
{
_isUpdating = true;
TraitCheckbox.Pressed = false;
UpdateSelectedStyle();
_isUpdating = false;
OnToggled?.Invoke(false);
}
// Show why it's disabled
AddStyleClass("TraitsEntryDisabled");
// Update tooltip to show failed conditions
if (_failedConditionTooltips.Count > 0)
{
var tooltipText = Loc.GetString("trait-conditions-not-met-tooltip",
("requirements", string.Join("\n", _failedConditionTooltips)));
TooltipSupplier = _ => CreateMarkupTooltip(tooltipText);
}
}
else
{
RemoveStyleClass("TraitsEntryDisabled");
UpdateConditionTooltips(); // Reset to normal tooltips
}
}
private void OnCheckboxToggled(BaseButton.ButtonToggledEventArgs args)
{
if (_isUpdating)
return;
if (!MeetsConditions)
{
// Prevent selection if conditions not met
_isUpdating = true;
TraitCheckbox.Pressed = false;
_isUpdating = false;
return;
}
UpdateSelectedStyle();
OnToggled?.Invoke(args.Pressed);
}
public void SetSelected(bool selected)
{
_isUpdating = true;
TraitCheckbox.Pressed = selected && MeetsConditions;
UpdateSelectedStyle();
_isUpdating = false;
}
public bool MeetsConditions { get; private set; } = true;
private void UpdateSelectedStyle()
{
if (TraitCheckbox.Pressed)
AddStyleClass("TraitsEntrySelected");
else
RemoveStyleClass("TraitsEntrySelected");
}
}

View File

@ -0,0 +1,224 @@
using Content.Client.Stylesheets;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using static Content.Client.Stylesheets.StylesheetHelpers;
namespace Content.Client._DV.Traits.UI;
[CommonSheetlet]
public sealed class TraitsSheetlet<T> : Sheetlet<T> where T : PalettedStylesheet
{
public override StyleRule[] GetRules(T sheet, object config)
{
// Color palette
// sorry but the default ColorPalette just sucks in terms of ligher/darker colors
var bgDark = Color.FromHex("#1a1a22");
var bgMedium = Color.FromHex("#22222a");
var bgLight = Color.FromHex("#2a2a35");
var bgLighter = Color.FromHex("#32323e");
var textPrimary = Color.FromHex("#e0e0e0");
var textSecondary = Color.FromHex("#a0a0a0");
var textMuted = Color.FromHex("#707070");
var accentGreen = Color.FromHex("#4ade80");
var accentYellow = Color.FromHex("#fbbf24");
var accentRed = Color.FromHex("#f87171");
var accentBlue = Color.FromHex("#60a5fa");
// StyleBoxes
var headerPanelBox = new StyleBoxFlat
{
BackgroundColor = bgLight,
BorderColor = bgLighter,
BorderThickness = new Thickness(0, 0, 0, 1)
};
headerPanelBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var searchBarBox = new StyleBoxFlat { BackgroundColor = bgMedium };
searchBarBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var searchInputBox = new StyleBoxFlat
{
BackgroundColor = bgDark,
ContentMarginLeftOverride = 8,
ContentMarginRightOverride = 8
};
var footerPanelBox = new StyleBoxFlat
{
BackgroundColor = bgMedium,
BorderColor = bgLighter,
BorderThickness = new Thickness(0, 1, 0, 0)
};
var categoryHeaderBox = new StyleBoxFlat { BackgroundColor = bgLight };
categoryHeaderBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var categoryHeaderButtonBox = new StyleBoxFlat { BackgroundColor = Color.Transparent };
categoryHeaderButtonBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var categoryContentBox = new StyleBoxFlat { BackgroundColor = bgMedium };
var categoryAccentBox = new StyleBoxFlat { BackgroundColor = accentBlue };
var entryPanelBox = new StyleBoxFlat
{
BackgroundColor = bgLight,
BorderColor = bgLighter,
BorderThickness = new Thickness(1)
};
entryPanelBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var entrySelectedBox = new StyleBoxFlat
{
BackgroundColor = Color.FromHex("#2a3a4a"),
BorderColor = accentBlue,
BorderThickness = new Thickness(1, 1, 1, 1)
};
entrySelectedBox.SetContentMarginOverride(StyleBox.Margin.All, 0);
var progressBarBgBox = new StyleBoxFlat
{
BackgroundColor = bgDark,
BorderColor = bgLighter,
BorderThickness = new Thickness(1)
};
var progressBarFillFull = new StyleBoxFlat { BackgroundColor = accentGreen };
var progressBarFillPartial = new StyleBoxFlat { BackgroundColor = accentYellow };
var progressBarFillLow = new StyleBoxFlat { BackgroundColor = accentRed };
var progressBarFillEmpty = new StyleBoxFlat { BackgroundColor = bgDark };
var rules = new List<StyleRule>
{
// ===== HEADER PANEL =====
E<PanelContainer>()
.Class("TraitsHeaderPanel")
.Panel(headerPanelBox),
E<Label>()
.Class("TraitsTitleLabel")
.Font(sheet.BaseFont.GetFont(14))
.FontColor(textPrimary),
E<Label>()
.Class("TraitsSubtitleLabel")
.Font(sheet.BaseFont.GetFont(11))
.FontColor(textSecondary),
E<Label>()
.Class("TraitsStatLabel")
.Font(sheet.BaseFont.GetFont(12))
.FontColor(accentBlue),
// ===== PROGRESS BAR =====
E<PanelContainer>()
.Class("TraitsProgressBarBg")
.Panel(progressBarBgBox),
E<PanelContainer>()
.Class("TraitsProgressBarFill")
.Panel(progressBarFillFull),
E<PanelContainer>()
.Class("TraitsProgressBarFull")
.Panel(progressBarFillFull),
E<PanelContainer>()
.Class("TraitsProgressBarPartial")
.Panel(progressBarFillPartial),
E<PanelContainer>()
.Class("TraitsProgressBarLow")
.Panel(progressBarFillLow),
E<PanelContainer>()
.Class("TraitsProgressBarEmpty")
.Panel(progressBarFillEmpty),
// ===== SEARCH BAR =====
E<PanelContainer>()
.Class("TraitsSearchBar")
.Panel(searchBarBox),
E<LineEdit>()
.Class("TraitsSearchInput")
.Prop(LineEdit.StylePropertyStyleBox, searchInputBox),
// ===== FOOTER =====
E<PanelContainer>()
.Class("TraitsFooterPanel")
.Panel(footerPanelBox),
E<Label>()
.Class("TraitsFooterText")
.Font(sheet.BaseFont.GetFont(10))
.FontColor(textMuted),
// ===== CATEGORY HEADER =====
E<PanelContainer>()
.Class("TraitsCategoryHeader")
.Panel(categoryHeaderBox),
E<Button>()
.Class("TraitsCategoryHeaderButton")
.Prop(Button.StylePropertyStyleBox, categoryHeaderButtonBox),
E<Label>()
.Class("TraitsCategoryExpandIcon")
.Font(sheet.BaseFont.GetFont(10))
.FontColor(textSecondary),
E<Label>()
.Class("TraitsCategoryNameLabel")
.Font(sheet.BaseFont.GetFont(12))
.FontColor(textPrimary),
E<Label>()
.Class("TraitsCategoryStatsLabel")
.Font(sheet.BaseFont.GetFont(10))
.FontColor(textSecondary),
E<Label>()
.Class("TraitsCategoryPointsLabel")
.Font(sheet.BaseFont.GetFont(10))
.FontColor(textMuted),
// ===== CATEGORY ACCENT =====
E<PanelContainer>()
.Class("TraitsCategoryAccent")
.Panel(categoryAccentBox),
// ===== CATEGORY CONTENT =====
E<PanelContainer>()
.Class("TraitsCategoryContent")
.Panel(categoryContentBox),
// ===== TRAIT ENTRY =====
E<PanelContainer>()
.Class("TraitsEntryPanel")
.Panel(entryPanelBox),
E<PanelContainer>()
.Class("TraitsEntryPanel")
.Class("TraitsEntrySelected")
.Panel(entrySelectedBox),
E<Label>()
.Class("TraitsEntryNameLabel")
.Font(sheet.BaseFont.GetFont(11))
.FontColor(textPrimary),
E<Label>()
.Class("TraitsEntryCostLabel")
.Font(sheet.BaseFont.GetFont(11)),
E<RichTextLabel>()
.Class("TraitsEntryDescriptionLabel")
.Font(sheet.BaseFont.GetFont(10))
.FontColor(textSecondary),
};
return rules.ToArray();
}
}

View File

@ -0,0 +1,68 @@
<BoxContainer xmlns="https://spacestation14.io"
Orientation="Vertical"
HorizontalExpand="True"
VerticalExpand="True">
<!-- Header Panel with Global Stats -->
<PanelContainer StyleClasses="TraitsHeaderPanel" Margin="0 0 0 8">
<BoxContainer Orientation="Vertical" Margin="12 10">
<!-- Title Row -->
<BoxContainer Orientation="Horizontal" Margin="0 0 0 8">
<Label Text="{Loc 'trait-editor-title'}"
StyleClasses="TraitsTitleLabel"/>
<Control HorizontalExpand="True"/>
<Label Name="GlobalTraitCountLabel"
StyleClasses="TraitsStatLabel"/>
</BoxContainer>
<!-- Points Progress Bar -->
<BoxContainer Orientation="Vertical" Margin="0 4 0 0">
<BoxContainer Orientation="Horizontal" Margin="0 0 0 4">
<Label Text="{Loc 'trait-editor-points-label'}"
StyleClasses="TraitsSubtitleLabel"/>
<Control HorizontalExpand="True"/>
<Label Name="GlobalPointsLabel"
StyleClasses="TraitsStatLabel"/>
</BoxContainer>
<!-- Progress Bar Container -->
<PanelContainer StyleClasses="TraitsProgressBarBg" SetHeight="12">
<PanelContainer Name="GlobalPointsBar"
StyleClasses="TraitsProgressBarFill"
HorizontalAlignment="Left"/>
</PanelContainer>
</BoxContainer>
</BoxContainer>
</PanelContainer>
<!-- Search Bar -->
<PanelContainer StyleClasses="TraitsSearchBar" Margin="0 0 0 8">
<LineEdit Name="SearchBar"
PlaceHolder="{Loc 'trait-editor-search-placeholder'}"
HorizontalExpand="True"
StyleClasses="TraitsSearchInput"
Margin="10 8"/>
</PanelContainer>
<!-- Categories Container (Scrollable) -->
<ScrollContainer VerticalExpand="True"
HScrollEnabled="False">
<BoxContainer Name="CategoriesContainer"
Orientation="Vertical"
HorizontalExpand="True"
Margin="0 0 4 0"/>
</ScrollContainer>
<!-- Footer Info -->
<PanelContainer StyleClasses="TraitsFooterPanel" Margin="0 8 0 0">
<BoxContainer Orientation="Horizontal" Margin="10 6">
<Label Name="FooterHintLabel"
Text="{Loc 'trait-editor-footer-hint'}"
StyleClasses="TraitsFooterText"/>
<Control HorizontalExpand="True"/>
<Label Name="FooterInfoLabel"
Text="{Loc 'trait-editor-footer-info'}"
StyleClasses="TraitsFooterText"/>
</BoxContainer>
</PanelContainer>
</BoxContainer>

View File

@ -0,0 +1,344 @@
using System.Linq;
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;
namespace Content.Client._DV.Traits.UI;
[GenerateTypedNameReferences]
public sealed partial class TraitsTab : BoxContainer
{
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
/// <summary>
/// Event fired when trait selection changes.
/// </summary>
public event Action<HashSet<ProtoId<TraitPrototype>>>? OnTraitsChanged;
private readonly Dictionary<ProtoId<TraitCategoryPrototype>, TraitCategory> _categoryUis = new();
private readonly HashSet<ProtoId<TraitPrototype>> _selectedTraits = new();
private int _maxGlobalTraits;
private int _maxGlobalPoints;
private int _currentTraitCount;
private int _currentPointsSpent;
private string _currentSearchText = string.Empty;
private bool _awaitingLayoutUpdate;
public TraitsTab()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
SearchBar.OnTextChanged += OnSearchTextChanged;
_prototype.PrototypesReloaded += OnProtoReload;
// Subscribe to CVars
_cfg.OnValueChanged(DCCVars.MaxTraitCount, OnMaxTraitCountChanged, true);
_cfg.OnValueChanged(DCCVars.MaxTraitPoints, OnMaxTraitPointsChanged, true);
PopulateCategories();
UpdateGlobalStats();
}
private void OnMaxTraitCountChanged(int value)
{
_maxGlobalTraits = value;
UpdateGlobalStats();
}
private void OnMaxTraitPointsChanged(int value)
{
_maxGlobalPoints = value;
UpdateGlobalStats();
}
private void OnProtoReload(PrototypesReloadedEventArgs args)
{
// Don't refresh if control has been disposed
if (Disposed)
return;
if (args.WasModified<TraitPrototype>() || args.WasModified<TraitCategoryPrototype>())
RefreshTraits();
}
public void RefreshTraits()
{
PopulateCategories();
UpdateGlobalStats();
}
private void PopulateCategories()
{
CategoriesContainer.RemoveAllChildren();
_categoryUis.Clear();
var categories = _prototype.EnumeratePrototypes<TraitCategoryPrototype>()
.OrderBy(c => c.Priority)
.ThenBy(c => Loc.GetString(c.Name))
.ToList();
var traitsByCategory = _prototype.EnumeratePrototypes<TraitPrototype>()
.GroupBy(t => t.Category)
.ToDictionary(g => g.Key, g => g.OrderBy(t => Loc.GetString(t.Name)).ToList());
foreach (var category in categories)
{
if (!traitsByCategory.TryGetValue(category.ID, out var traits) || traits.Count == 0)
continue;
var categoryUi = new TraitCategory(category, traits);
categoryUi.OnTraitToggled += OnTraitToggled;
_categoryUis[category.ID] = categoryUi;
CategoriesContainer.AddChild(categoryUi);
}
// Apply current filters and conditions
ApplySearchFilter();
UpdateAllConditions();
}
private void OnTraitToggled(ProtoId<TraitPrototype> traitId, bool selected)
{
var trait = _prototype.Index(traitId);
if (selected)
{
// Check global limits
if (_currentTraitCount >= _maxGlobalTraits)
{
RevertTraitToggle(traitId);
return;
}
if (_currentPointsSpent + trait.Cost > _maxGlobalPoints)
{
RevertTraitToggle(traitId);
return;
}
// Check category limits
if (_categoryUis.TryGetValue(trait.Category, out var categoryUi))
{
var categoryProto = _prototype.Index(trait.Category);
if (categoryProto.MaxTraits.HasValue &&
categoryUi.SelectedCount >= categoryProto.MaxTraits.Value)
{
RevertTraitToggle(traitId);
return;
}
if (categoryProto.MaxPoints.HasValue &&
categoryUi.PointsSpent + trait.Cost > categoryProto.MaxPoints.Value)
{
RevertTraitToggle(traitId);
return;
}
// Check if trait meets conditions
if (!categoryUi.TraitMeetsConditions(traitId))
{
RevertTraitToggle(traitId);
return;
}
}
// Check conflicts
foreach (var conflict in trait.Conflicts)
{
if (!_selectedTraits.Contains(conflict))
continue;
RevertTraitToggle(traitId);
return;
}
_selectedTraits.Add(traitId);
_currentTraitCount++;
_currentPointsSpent += trait.Cost;
}
else
{
_selectedTraits.Remove(traitId);
_currentTraitCount--;
_currentPointsSpent -= trait.Cost;
}
UpdateGlobalStats();
UpdateCategoryStats(trait.Category);
OnTraitsChanged?.Invoke(_selectedTraits);
}
private void RevertTraitToggle(ProtoId<TraitPrototype> traitId)
{
var trait = _prototype.Index(traitId);
if (_categoryUis.TryGetValue(trait.Category, out var categoryUi))
{
categoryUi.SetTraitSelected(traitId, _selectedTraits.Contains(traitId));
}
}
private void UpdateGlobalStats()
{
GlobalTraitCountLabel.Text = $"{_currentTraitCount} / {_maxGlobalTraits}";
GlobalPointsLabel.Text = $"{_maxGlobalPoints - _currentPointsSpent} / {_maxGlobalPoints}";
// Calculate remaining points (clamped to not go below 0 in display)
var remainingPoints = _maxGlobalPoints - _currentPointsSpent;
GlobalPointsLabel.Text = $"{remainingPoints} / {_maxGlobalPoints}";
// Calculate progress bar percentage - clamp between 0 and 1
var percentage = _maxGlobalPoints > 0
? Math.Clamp((float)remainingPoints / _maxGlobalPoints, 0f, 1f)
: 0f;
// Update progress bar using percentage-based sizing
var parent = GlobalPointsBar.Parent;
if (parent != null)
{
var parentWidth = parent.Width;
// If parent width is 0 (not laid out yet), defer until layout happens
if (parentWidth > 0)
{
GlobalPointsBar.SetWidth = (int)(parentWidth * percentage);
_awaitingLayoutUpdate = false;
}
else if (!_awaitingLayoutUpdate)
{
// Schedule update after parent layout (only once)
_awaitingLayoutUpdate = true;
parent.OnResized += OnProgressBarParentResized;
}
}
// Update progress bar color class
GlobalPointsBar.RemoveStyleClass("TraitsProgressBarFull");
GlobalPointsBar.RemoveStyleClass("TraitsProgressBarPartial");
GlobalPointsBar.RemoveStyleClass("TraitsProgressBarLow");
GlobalPointsBar.RemoveStyleClass("TraitsProgressBarEmpty");
GlobalPointsBar.AddStyleClass(percentage switch
{
>= 0.99f => "TraitsProgressBarFull",
>= 0.5f => "TraitsProgressBarPartial",
> 0f => "TraitsProgressBarLow",
_ => "TraitsProgressBarEmpty"
});
}
private void OnProgressBarParentResized()
{
_awaitingLayoutUpdate = false;
UpdateGlobalStats();
}
private void UpdateCategoryStats(ProtoId<TraitCategoryPrototype> categoryId)
{
if (_categoryUis.TryGetValue(categoryId, out var categoryUi))
{
categoryUi.UpdateStats();
}
}
private void OnSearchTextChanged(LineEdit.LineEditEventArgs args)
{
_currentSearchText = args.Text.Trim();
ApplySearchFilter();
}
private void ApplySearchFilter()
{
foreach (var (_, categoryUi) in _categoryUis)
{
categoryUi.FilterTraits(_currentSearchText);
}
}
private void UpdateAllConditions()
{
RecalculateStats();
}
private void RecalculateStats()
{
_currentTraitCount = 0;
_currentPointsSpent = 0;
// Rebuild selected traits based on what's actually selected in the UI
var previouslySelected = new HashSet<ProtoId<TraitPrototype>>(_selectedTraits);
_selectedTraits.Clear();
foreach (var (_, categoryUi) in _categoryUis)
{
// Get the selected traits from this category
var selectedInCategory = categoryUi.GetSelectedTraitIds();
foreach (var traitId in selectedInCategory)
{
if (!_prototype.TryIndex(traitId, out var trait))
continue;
_selectedTraits.Add(traitId);
_currentTraitCount++;
_currentPointsSpent += trait.Cost;
}
}
UpdateGlobalStats();
foreach (var (categoryId, _) in _categoryUis)
{
UpdateCategoryStats(categoryId);
}
// Fire event if selection changed
if (!_selectedTraits.SetEquals(previouslySelected))
{
OnTraitsChanged?.Invoke(_selectedTraits);
}
}
/// <summary>
/// Sets the currently selected traits (e.g., when loading a profile).
/// </summary>
public void SetSelectedTraits(IEnumerable<ProtoId<TraitPrototype>> traits)
{
// Clear current selection
foreach (var (_, categoryUi) in _categoryUis)
{
categoryUi.ClearSelection();
}
_selectedTraits.Clear();
_currentTraitCount = 0;
_currentPointsSpent = 0;
// Apply new selection
foreach (var traitId in traits)
{
if (!_prototype.TryIndex(traitId, out var trait))
continue;
_selectedTraits.Add(traitId);
_currentTraitCount++;
_currentPointsSpent += trait.Cost;
if (_categoryUis.TryGetValue(trait.Category, out var categoryUi))
{
categoryUi.SetTraitSelected(traitId, true);
}
}
UpdateGlobalStats();
foreach (var (categoryId, _) in _categoryUis)
{
UpdateCategoryStats(categoryId);
}
}
}

View File

@ -0,0 +1,797 @@
using System.Collections.Generic;
using System.Reflection;
using Content.Server._DV.Traits;
using Content.Shared._DV.Traits;
using Content.Shared._DV.Traits.Conditions;
using Content.Shared._DV.Traits.Effects;
using Content.Shared.Hands.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Nutrition.Components;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
namespace Content.IntegrationTests.Tests._DV;
/// <summary>
/// Comprehensive integration tests for the trait system.
/// Tests all conditions, effects, and validation logic.
/// </summary>
[TestFixture]
[TestOf(typeof(TraitSystemTest))]
public sealed partial class TraitSystemTest
{
[TestPrototypes]
private const string Prototypes = @"
# Test Trait Categories
- type: traitCategory
id: TestCategoryUnlimited
name: trait-dysgraphia-name
maxTraits: null
maxPoints: null
- type: traitCategory
id: TestCategoryLimited
name: trait-dysgraphia-name
maxTraits: 2
maxPoints: 10
# Test Traits - Conditions
- type: trait
id: TestTraitHasComp
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryUnlimited
cost: 0
conditions:
- !type:HasCompCondition
component: Hunger
effects:
- !type:AddCompsEffect
components:
- type: Test
# Test Traits - Effects
- type: trait
id: TestTraitAddComps
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryUnlimited
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: Test
- type: Hunger
- type: trait
id: TestTraitOverrideComps
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryUnlimited
cost: 0
effects:
- !type:OverrideCompsEffect
components:
- type: Hunger
- type: trait
id: TestTraitRemComps
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryUnlimited
cost: 0
effects:
- !type:RemCompsEffect
components:
- Hunger
- Thirst
- type: trait
id: TestTraitSpawnItem
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryUnlimited
cost: 0
effects:
- !type:SpawnItemInHandEffect
item: Pen
# Test Traits - Validation
- type: trait
id: TestTraitConflictA
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryUnlimited
cost: 0
conflicts:
- TestTraitConflictB
effects:
- !type:AddCompsEffect
components:
- type: Test
- type: trait
id: TestTraitConflictB
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryUnlimited
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: Test
- type: trait
id: TestTraitLimited1
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryLimited
cost: 5
effects:
- !type:AddCompsEffect
components:
- type: Test
- type: trait
id: TestTraitLimited2
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryLimited
cost: 5
effects:
- !type:AddCompsEffect
components:
- type: Test
- type: trait
id: TestTraitLimited3
name: trait-dysgraphia-name
description: trait-dysgraphia-name
category: TestCategoryLimited
cost: 5
effects:
- !type:AddCompsEffect
components:
- type: Test
";
#region Condition Tests
[RegisterComponent]
private sealed partial class TestComponent : Component;
[Test]
public async Task HasCompCondition_WithComponent_ReturnsTrue()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
entMan.AddComponent<HungerComponent>(player);
var condition = new HasCompCondition { Component = "Hunger" };
var ctx = CreateContext(entMan, protoMan, factory, player);
Assert.That(condition.Evaluate(ctx), Is.True, "HasCompCondition should return true when component exists");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task HasCompCondition_WithoutComponent_ReturnsFalse()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var condition = new HasCompCondition { Component = "Hunger" };
var ctx = CreateContext(entMan, protoMan, factory, player);
Assert.That(condition.Evaluate(ctx),
Is.False,
"HasCompCondition should return false when component doesn't exist");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task HasCompCondition_Inverted_ReturnsOpposite()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
entMan.AddComponent<HungerComponent>(player);
var condition = new HasCompCondition { Component = "Hunger", Invert = true };
var ctx = CreateContext(entMan, protoMan, factory, player);
Assert.That(condition.Evaluate(ctx),
Is.False,
"Inverted HasCompCondition should return false when component exists");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task HasJobCondition_MatchingJob_ReturnsTrue()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var condition = new HasJobCondition { Job = "MedicalDoctor" };
var ctx = CreateContext(entMan, protoMan, factory, player, "MedicalDoctor");
Assert.That(condition.Evaluate(ctx), Is.True, "HasJobCondition should return true for matching job");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task HasJobCondition_DifferentJob_ReturnsFalse()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var condition = new HasJobCondition { Job = "MedicalDoctor" };
var ctx = CreateContext(entMan, protoMan, factory, player, "SecurityOfficer");
Assert.That(condition.Evaluate(ctx), Is.False, "HasJobCondition should return false for different job");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task InDepartmentCondition_JobInDepartment_ReturnsTrue()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var condition = new InDepartmentCondition { Department = "Medical" };
var ctx = CreateContext(entMan, protoMan, factory, player, "MedicalDoctor");
Assert.That(condition.Evaluate(ctx),
Is.True,
"InDepartmentCondition should return true when job is in department");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task InDepartmentCondition_JobNotInDepartment_ReturnsFalse()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var condition = new InDepartmentCondition { Department = "Medical" };
var ctx = CreateContext(entMan, protoMan, factory, player, "SecurityOfficer");
Assert.That(condition.Evaluate(ctx),
Is.False,
"InDepartmentCondition should return false when job is not in department");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task IsSpeciesCondition_MatchingSpecies_ReturnsTrue()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var condition = new IsSpeciesCondition { Species = "Human" };
var ctx = CreateContext(entMan, protoMan, factory, player, speciesId: "Human");
Assert.That(condition.Evaluate(ctx), Is.True, "IsSpeciesCondition should return true for matching species");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task IsSpeciesCondition_DifferentSpecies_ReturnsFalse()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var condition = new IsSpeciesCondition { Species = "Human" };
var ctx = CreateContext(entMan, protoMan, factory, player, speciesId: "Vox");
Assert.That(condition.Evaluate(ctx),
Is.False,
"IsSpeciesCondition should return false for different species");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
#endregion
#region Effect Tests
[Test]
public async Task AddCompsEffect_AddsComponents()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
Assert.That(entMan.HasComponent<HungerComponent>(player),
Is.False,
"Player should not start with HungerComponent");
var trait = protoMan.Index(new ProtoId<TraitPrototype>("TestTraitAddComps"));
var ctx = CreateEffectContext(entMan, protoMan, factory, player);
foreach (var effect in trait.Effects)
{
effect.Apply(ctx);
}
Assert.That(entMan.HasComponent<HungerComponent>(player),
Is.True,
"AddCompsEffect should add HungerComponent");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task AddCompsEffect_DoesNotOverwrite()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var hungerBefore = entMan.AddComponent<HungerComponent>(player);
var trait = protoMan.Index(new ProtoId<TraitPrototype>("TestTraitAddComps"));
var ctx = CreateEffectContext(entMan, protoMan, factory, player);
foreach (var effect in trait.Effects)
{
effect.Apply(ctx);
}
var hungerAfter = entMan.GetComponent<HungerComponent>(player);
Assert.That(hungerAfter,
Is.SameAs(hungerBefore),
"AddCompsEffect should not replace existing component instance");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task OverrideCompsEffect_OverwritesComponent()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var hungerBefore = entMan.AddComponent<HungerComponent>(player);
var trait = protoMan.Index(new ProtoId<TraitPrototype>("TestTraitOverrideComps"));
var ctx = CreateEffectContext(entMan, protoMan, factory, player);
foreach (var effect in trait.Effects)
{
effect.Apply(ctx);
}
var hungerAfter = entMan.GetComponent<HungerComponent>(player);
Assert.That(hungerAfter,
Is.Not.SameAs(hungerBefore),
"OverrideCompsEffect should replace existing component instance");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task RemCompsEffect_RemovesComponents()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
var factory = server.ResolveDependency<IComponentFactory>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
entMan.AddComponent<HungerComponent>(player);
entMan.AddComponent<ThirstComponent>(player);
Assert.That(entMan.HasComponent<HungerComponent>(player),
Is.True,
"Player should start with HungerComponent");
Assert.That(entMan.HasComponent<ThirstComponent>(player),
Is.True,
"Player should start with ThirstComponent");
var trait = protoMan.Index(new ProtoId<TraitPrototype>("TestTraitRemComps"));
var ctx = CreateEffectContext(entMan, protoMan, factory, player);
foreach (var effect in trait.Effects)
{
effect.Apply(ctx);
}
Assert.That(entMan.HasComponent<HungerComponent>(player),
Is.False,
"RemCompsEffect should remove HungerComponent");
Assert.That(entMan.HasComponent<ThirstComponent>(player),
Is.False,
"RemCompsEffect should remove ThirstComponent");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task SpawnItemInHandEffect_SpawnsItem()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var protoMan = server.ResolveDependency<IPrototypeManager>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity("MobHuman", MapCoordinates.Nullspace);
var handsSys = entMan.System<SharedHandsSystem>();
var hands = entMan.GetComponent<HandsComponent>(player);
Assert.That(handsSys.GetActiveItem((player, hands)), Is.Null, "Player should start with empty hands");
var traitSys = entMan.System<TraitSystem>();
var trait = protoMan.Index(new ProtoId<TraitPrototype>("TestTraitSpawnItem"));
// We need to use reflection to call the private ApplyTrait method
var method = typeof(TraitSystem).GetMethod("ApplyTrait",
BindingFlags.NonPublic | BindingFlags.Instance);
method?.Invoke(traitSys, new object[] { player, trait });
var item = handsSys.GetActiveItem((player, hands));
Assert.That(item, Is.Not.Null, "SpawnItemInHandEffect should spawn item in hand");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
#endregion
#region Validation Tests
[Test]
public async Task RespectsConflicts()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
var selectedTraits = new HashSet<ProtoId<TraitPrototype>>
{
"TestTraitConflictA",
"TestTraitConflictB",
};
var traitSys = entMan.System<TraitSystem>();
var method = typeof(TraitSystem).GetMethod("ValidateTraits",
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
Assert.Multiple(() =>
{
Assert.That(validTraits?.Count, Is.EqualTo(1), "Only one conflicting trait should be valid");
Assert.That(validTraits.Contains("TestTraitConflictA"), Is.True, "First trait should be kept");
Assert.That(validTraits.Contains("TestTraitConflictB"),
Is.False,
"Conflicting trait should be rejected");
});
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task RespectsCategoryLimits()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
// TestCategoryLimited has maxTraits: 2
var selectedTraits = new HashSet<ProtoId<TraitPrototype>>
{
"TestTraitLimited1",
"TestTraitLimited2",
"TestTraitLimited3",
};
var traitSys = entMan.System<TraitSystem>();
var method = typeof(TraitSystem).GetMethod("ValidateTraits",
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
Assert.That(validTraits?.Count, Is.EqualTo(2), "Should respect category maxTraits limit");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task RespectsCategoryPointLimits()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
// TestCategoryLimited has maxPoints: 10, each trait costs 5
var selectedTraits = new HashSet<ProtoId<TraitPrototype>>
{
"TestTraitLimited1",
"TestTraitLimited2",
"TestTraitLimited3", // This would exceed the 10 point limit
};
var traitSys = entMan.System<TraitSystem>();
var method = typeof(TraitSystem).GetMethod("ValidateTraits",
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
Assert.That(validTraits?.Count, Is.EqualTo(2), "Should respect category maxPoints limit");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task ChecksConditionsOnSpawn()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
entMan.AddComponent<HungerComponent>(player);
// Trait requires HungerComponent
var selectedTraits = new HashSet<ProtoId<TraitPrototype>>
{
"TestTraitHasComp",
};
var traitSys = entMan.System<TraitSystem>();
var method = typeof(TraitSystem).GetMethod("ValidateTraits",
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
Assert.That(validTraits?.Contains("TestTraitHasComp"), Is.True, "Trait with met condition should be valid");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
[Test]
public async Task RejectsTraitsWithUnmetConditions()
{
await using var pair = await PoolManager.GetServerClient(new PoolSettings { Dirty = true });
var server = pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
await server.WaitAssertion(() =>
{
var player = entMan.SpawnEntity(null, MapCoordinates.Nullspace);
// Player does NOT have HungerComponent
// Trait requires HungerComponent
var selectedTraits = new HashSet<ProtoId<TraitPrototype>>
{
"TestTraitHasComp",
};
var traitSys = entMan.System<TraitSystem>();
var method = typeof(TraitSystem).GetMethod("ValidateTraits",
BindingFlags.NonPublic | BindingFlags.Instance);
var validTraits = (HashSet<ProtoId<TraitPrototype>>)method?.Invoke(traitSys,
new object[] { player, selectedTraits, null, null, null });
Assert.That(validTraits?.Contains("TestTraitHasComp"),
Is.False,
"Trait with unmet condition should be rejected");
entMan.DeleteEntity(player);
});
await pair.CleanReturnAsync();
}
#endregion
#region Helper Methods
private static TraitConditionContext CreateContext(
IEntityManager entMan,
IPrototypeManager protoMan,
IComponentFactory factory,
EntityUid player,
string? jobId = null,
string? speciesId = null)
{
return new TraitConditionContext
{
Player = player,
Session = null,
EntMan = entMan,
Proto = protoMan,
CompFactory = factory,
LogMan = IoCManager.Resolve<ILogManager>(),
JobId = jobId,
SpeciesId = speciesId,
};
}
private static TraitEffectContext CreateEffectContext(
IEntityManager entMan,
IPrototypeManager protoMan,
IComponentFactory factory,
EntityUid player)
{
return new TraitEffectContext
{
Player = player,
EntMan = entMan,
Proto = protoMan,
CompFactory = factory,
LogMan = IoCManager.Resolve<ILogManager>(),
Transform = entMan.GetComponent<TransformComponent>(player),
};
}
#endregion
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class TruncateTraitTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("TRUNCATE TABLE trait RESTART IDENTITY;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Not like truncate operations can be reversed
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
/// <inheritdoc />
public partial class TruncateTraitTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DELETE FROM trait;
DELETE FROM sqlite_sequence WHERE name='trait';
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Cannot reverse
}
}
}

View File

@ -17,6 +17,7 @@ using Robust.Shared.Map;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using Content.Server.Traits.Assorted; // DV
namespace Content.Server.Cloning; namespace Content.Server.Cloning;

View File

@ -25,6 +25,7 @@ using Robust.Shared.Utility;
using Content.Server._CD.Records; // CD - Character Records using Content.Server._CD.Records; // CD - Character Records
using Content.Shared._CD.Records; // CD - Character Records using Content.Shared._CD.Records; // CD - Character Records
using Content.Shared._DV.Tips; // DV - Tips using Content.Shared._DV.Tips; // DV - Tips
using Content.Shared._DV.Traits; // DV - Traits
namespace Content.Server.Database namespace Content.Server.Database
{ {

View File

@ -1,10 +0,0 @@
namespace Content.Server.Traits.Assorted;
/// <summary>
/// This is used for the uncloneable trait.
/// </summary>
[RegisterComponent]
public sealed partial class UncloneableComponent : Component
{
}

View File

@ -1,70 +1,70 @@
using Content.Shared.GameTicking; // using Content.Shared.GameTicking; // DeltaV - Traits rework
using Content.Shared.Hands.Components; // using Content.Shared.Hands.Components;
using Content.Shared.Hands.EntitySystems; // using Content.Shared.Hands.EntitySystems;
using Content.Shared.Roles; // using Content.Shared.Roles;
using Content.Shared.Traits; // using Content.Shared.Traits;
using Content.Shared.Whitelist; // using Content.Shared.Whitelist;
using Robust.Shared.Prototypes; // using Robust.Shared.Prototypes;
//
namespace Content.Server.Traits; // namespace Content.Server.Traits;
//
public sealed class TraitSystem : EntitySystem // public sealed class TraitSystem : EntitySystem
{ // {
[Dependency] private readonly IPrototypeManager _prototypeManager = default!; // [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly SharedHandsSystem _sharedHandsSystem = default!; // [Dependency] private readonly SharedHandsSystem _sharedHandsSystem = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; // [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
//
public override void Initialize() // public override void Initialize()
{ // {
base.Initialize(); // base.Initialize();
//
SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete); // SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete);
} // }
//
// When the player is spawned in, add all trait components selected during character creation // // When the player is spawned in, add all trait components selected during character creation
private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent args) // private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent args)
{ // {
// Check if player's job allows to apply traits // // Check if player's job allows to apply traits
if (args.JobId == null || // if (args.JobId == null ||
!_prototypeManager.Resolve<JobPrototype>(args.JobId, out var protoJob) || // !_prototypeManager.Resolve<JobPrototype>(args.JobId, out var protoJob) ||
!protoJob.ApplyTraits) // !protoJob.ApplyTraits)
{ // {
return; // return;
} // }
//
foreach (var traitId in args.Profile.TraitPreferences) // foreach (var traitId in args.Profile.TraitPreferences)
{ // {
if (!_prototypeManager.TryIndex<TraitPrototype>(traitId, out var traitPrototype)) // if (!_prototypeManager.TryIndex<TraitPrototype>(traitId, out var traitPrototype))
{ // {
Log.Error($"No trait found with ID {traitId}!"); // Log.Error($"No trait found with ID {traitId}!");
return; // return;
} // }
//
if (_whitelistSystem.IsWhitelistFail(traitPrototype.Whitelist, args.Mob) || // if (_whitelistSystem.IsWhitelistFail(traitPrototype.Whitelist, args.Mob) ||
_whitelistSystem.IsBlacklistPass(traitPrototype.Blacklist, args.Mob)) // _whitelistSystem.IsBlacklistPass(traitPrototype.Blacklist, args.Mob))
continue; // continue;
//
// Add all components required by the prototype // // Add all components required by the prototype
EntityManager.AddComponents(args.Mob, traitPrototype.Components, false); // EntityManager.AddComponents(args.Mob, traitPrototype.Components, false);
//
// Begin DeltaV - Add overridden components // // Begin DeltaV - Add overridden components
if(traitPrototype.OverriddenComponents != null) // if(traitPrototype.OverriddenComponents != null)
EntityManager.AddComponents(args.Mob, traitPrototype.OverriddenComponents, true); // EntityManager.AddComponents(args.Mob, traitPrototype.OverriddenComponents, true);
// End DeltaV // // End DeltaV
//
// Add item required by the trait // // Add item required by the trait
if (traitPrototype.TraitGear == null) // if (traitPrototype.TraitGear == null)
continue; // continue;
//
if (!TryComp(args.Mob, out HandsComponent? handsComponent)) // if (!TryComp(args.Mob, out HandsComponent? handsComponent))
continue; // continue;
//
var coords = Transform(args.Mob).Coordinates; // var coords = Transform(args.Mob).Coordinates;
var inhandEntity = Spawn(traitPrototype.TraitGear, coords); // var inhandEntity = Spawn(traitPrototype.TraitGear, coords);
_sharedHandsSystem.TryPickup(args.Mob, // _sharedHandsSystem.TryPickup(args.Mob,
inhandEntity, // inhandEntity,
checkActionBlocker: false, // checkActionBlocker: false,
handsComp: handsComponent); // handsComp: handsComponent);
} // }
} // }
} // }

View File

@ -0,0 +1,263 @@
using Content.Shared._DV.CCVars;
using Content.Shared._DV.Traits;
using Content.Shared._DV.Traits.Conditions;
using Content.Shared._DV.Traits.Effects;
using Content.Shared.GameTicking;
using Content.Shared.Hands.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Humanoid;
using Content.Shared.Roles;
using Robust.Shared.Configuration;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Server._DV.Traits;
/// <summary>
/// Server system that validates and applies traits to players on spawn.
/// </summary>
public sealed class TraitSystem : EntitySystem
{
[Dependency] private readonly IComponentFactory _factory = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly ILogManager _log = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
private int _maxTraitCount;
private int _maxTraitPoints;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete);
Subs.CVar(_config, DCCVars.MaxTraitCount, value => _maxTraitCount = value, true);
Subs.CVar(_config, DCCVars.MaxTraitPoints, value => _maxTraitPoints = value, true);
}
private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent args)
{
// Check if player's job allows traits
if (args.JobId == null ||
!_prototype.TryIndex<JobPrototype>(args.JobId, out var jobProto) ||
!jobProto.ApplyTraits)
return;
// Get species ID for condition checking
string? speciesId = null;
if (TryComp<HumanoidAppearanceComponent>(args.Mob, out var humanoid))
speciesId = humanoid.Species;
// Validate and collect valid traits
var validTraits = ValidateTraits(args.Mob, args.Profile.TraitPreferences, args.Player, args.JobId, speciesId);
// Apply valid traits
foreach (var traitId in validTraits)
{
if (!_prototype.TryIndex(traitId, out var trait))
continue;
ApplyTrait(args.Mob, trait);
}
}
/// <summary>
/// Validates a set of trait selections against all rules and returns the valid subset.
/// </summary>
private HashSet<ProtoId<TraitPrototype>> ValidateTraits(
EntityUid player,
IReadOnlySet<ProtoId<TraitPrototype>> selectedTraits,
ICommonSession? session,
string? jobId,
string? speciesId)
{
var validTraits = new HashSet<ProtoId<TraitPrototype>>();
var totalPoints = 0;
var traitCount = 0;
var categoryTraitCounts = new Dictionary<ProtoId<TraitCategoryPrototype>, int>();
var categoryPointTotals = new Dictionary<ProtoId<TraitCategoryPrototype>, int>();
// Build condition context
var conditionCtx = new TraitConditionContext
{
Player = player,
Session = session,
EntMan = EntityManager,
Proto = _prototype,
CompFactory = _factory,
LogMan = _log,
JobId = jobId,
SpeciesId = speciesId,
};
foreach (var traitId in selectedTraits)
{
if (!_prototype.TryIndex(traitId, out var trait))
{
Log.Warning($"Unknown trait ID in player preferences: {traitId}");
continue;
}
// Check global trait count limit
if (traitCount >= _maxTraitCount)
{
Log.Warning($"Trait {traitId} rejected: global trait count limit ({_maxTraitCount}) exceeded");
continue;
}
// Check global points limit
if (totalPoints + trait.Cost > _maxTraitPoints)
{
Log.Warning(
$"Trait {traitId} rejected: global points limit ({_maxTraitPoints}) would be exceeded");
continue;
}
// Check category limits
if (!ValidateCategoryLimits(trait, categoryTraitCounts, categoryPointTotals))
{
Log.Warning($"Trait {traitId} rejected: category limits exceeded");
continue;
}
// Check conflicts with already selected traits
var hasConflict = false;
foreach (var validTraitId in validTraits)
{
// Check if current trait conflicts with valid trait
if (trait.Conflicts.Contains(validTraitId))
{
Log.Warning($"Trait {traitId} rejected: conflicts with {validTraitId}");
hasConflict = true;
break;
}
// Check if valid trait conflicts with current trait
if (_prototype.TryIndex(validTraitId, out var validTrait) &&
validTrait.Conflicts.Contains(traitId))
{
Log.Warning($"Trait {traitId} rejected: {validTraitId} conflicts with it");
hasConflict = true;
break;
}
}
if (hasConflict)
continue;
// Check all conditions
if (!CheckConditions(trait, conditionCtx))
{
Log.Warning($"Trait {traitId} rejected: conditions not met");
continue;
}
// Trait is valid, add it
validTraits.Add(traitId);
totalPoints += trait.Cost;
traitCount++;
// Update category tracking
categoryTraitCounts.TryGetValue(trait.Category, out var catCount);
categoryTraitCounts[trait.Category] = catCount + 1;
categoryPointTotals.TryGetValue(trait.Category, out var catPoints);
categoryPointTotals[trait.Category] = catPoints + trait.Cost;
}
return validTraits;
}
/// <summary>
/// Validates that adding a trait wouldn't exceed category-specific limits.
/// </summary>
private bool ValidateCategoryLimits(
TraitPrototype trait,
Dictionary<ProtoId<TraitCategoryPrototype>, int> categoryTraitCounts,
Dictionary<ProtoId<TraitCategoryPrototype>, int> categoryPointTotals)
{
if (!_prototype.TryIndex(trait.Category, out var category))
return true; // Unknown category, allow it
categoryTraitCounts.TryGetValue(trait.Category, out var currentCount);
categoryPointTotals.TryGetValue(trait.Category, out var currentPoints);
// Check category trait count limit
if (category.MaxTraits.HasValue && currentCount >= category.MaxTraits.Value)
return false;
// Check category points limit
if (category.MaxPoints.HasValue && currentPoints + trait.Cost > category.MaxPoints.Value)
return false;
return true;
}
/// <summary>
/// Checks all conditions on a trait.
/// </summary>
private bool CheckConditions(TraitPrototype trait, TraitConditionContext ctx)
{
foreach (var condition in trait.Conditions)
{
if (!condition.Evaluate(ctx))
return false;
}
return true;
}
/// <summary>
/// Applies a trait's effects to an entity.
/// </summary>
private void ApplyTrait(EntityUid player, TraitPrototype trait)
{
var transform = Transform(player);
var effectCtx = new TraitEffectContext
{
Player = player,
EntMan = EntityManager,
Proto = _prototype,
CompFactory = _factory,
LogMan = _log,
Transform = transform,
};
foreach (var effect in trait.Effects)
{
try
{
// Handle SpawnItemInHandEffect specially since it needs server-side systems
if (effect is SpawnItemInHandEffect spawnEffect)
ApplySpawnItemEffect(player, spawnEffect, transform);
else
effect.Apply(effectCtx);
}
catch (Exception e)
{
Log.Error($"Error applying effect {effect.GetType().Name} for trait {trait.ID}: {e}");
}
}
}
/// <summary>
/// Handles the SpawnItemInHandEffect since it requires server-side systems.
/// </summary>
private void ApplySpawnItemEffect(EntityUid player, SpawnItemInHandEffect effect, TransformComponent transform)
{
if (!TryComp<HandsComponent>(player, out var hands))
{
Log.Warning("Cannot spawn trait item: player has no hands component");
return;
}
var coords = transform.Coordinates;
var item = Spawn(effect.Item, coords);
if (!_hands.TryPickup(player, item, checkActionBlocker: false, handsComp: hands))
Log.Debug($"Could not pick up trait item {effect.Item}, leaving at feet");
}
}

View File

@ -16,6 +16,7 @@ using Robust.Shared.Random;
using Robust.Shared.Serialization; using Robust.Shared.Serialization;
using Robust.Shared.Utility; using Robust.Shared.Utility;
using Content.Shared._CD.Records; // CD - Character Records using Content.Shared._CD.Records; // CD - Character Records
using Content.Shared._DV.Traits; // DeltaV - Traits rework
namespace Content.Shared.Preferences namespace Content.Shared.Preferences
{ {
@ -444,12 +445,12 @@ namespace Content.Shared.Preferences
// Category not found so dump it. // Category not found so dump it.
TraitCategoryPrototype? traitCategory = null; TraitCategoryPrototype? traitCategory = null;
if (category != null && !protoManager.Resolve(category, out traitCategory)) if (!protoManager.Resolve(category, out traitCategory)) // DeltaV 13/01/26 - Traits: Category is no longer nullable
return new(this); return new(this);
var list = new HashSet<ProtoId<TraitPrototype>>(_traitPreferences) { traitId }; var list = new HashSet<ProtoId<TraitPrototype>>(_traitPreferences) { traitId };
if (traitCategory == null || traitCategory.MaxTraitPoints < 0) if (traitCategory.MaxPoints < 0) // DeltaV 13/01/26 - Traits: Changed to MaxPoints
{ {
return new(this) return new(this)
{ {
@ -470,7 +471,7 @@ namespace Content.Shared.Preferences
count += otherProto.Cost; count += otherProto.Cost;
} }
if (count > traitCategory.MaxTraitPoints && traitProto.Cost != 0) if (count > traitCategory.MaxPoints && traitProto.Cost != 0) // DeltaV 13/01/26 - Traits: Changed to MaxPoints
{ {
return new(this); return new(this);
} }
@ -724,11 +725,11 @@ namespace Content.Shared.Preferences
continue; continue;
// Always valid. // Always valid.
if (traitProto.Category == null) // if (traitProto.Category == null) // DeltaV 13/01/26 - Traits rework
{ // {
result.Add(trait); // result.Add(trait);
continue; // continue;
} // }
// No category so dump it. // No category so dump it.
if (!protoManager.Resolve(traitProto.Category, out var category)) if (!protoManager.Resolve(traitProto.Category, out var category))
@ -738,7 +739,7 @@ namespace Content.Shared.Preferences
existing += traitProto.Cost; existing += traitProto.Cost;
// Too expensive. // Too expensive.
if (existing > category.MaxTraitPoints) if (existing > category.MaxPoints) // DeltaV 13/01/26 - Traits: Was MaxTraitPoints
continue; continue;
groups[category.ID] = existing; groups[category.ID] = existing;

View File

@ -1,28 +1,28 @@
using Robust.Shared.Prototypes; // using Robust.Shared.Prototypes; // DeltaV - Traits rework
//
namespace Content.Shared.Traits; // namespace Content.Shared.Traits;
//
/// <summary> // /// <summary>
/// Traits category with general settings. Allows you to limit the number of taken traits in one category // /// Traits category with general settings. Allows you to limit the number of taken traits in one category
/// </summary> // /// </summary>
[Prototype] // [Prototype]
public sealed partial class TraitCategoryPrototype : IPrototype // public sealed partial class TraitCategoryPrototype : IPrototype
{ // {
public const string Default = "Default"; // public const string Default = "Default";
//
[ViewVariables] // [ViewVariables]
[IdDataField] // [IdDataField]
public string ID { get; private set; } = default!; // public string ID { get; private set; } = default!;
//
/// <summary> // /// <summary>
/// Name of the trait category displayed in the UI // /// Name of the trait category displayed in the UI
/// </summary> // /// </summary>
[DataField] // [DataField]
public LocId Name { get; private set; } = string.Empty; // public LocId Name { get; private set; } = string.Empty;
//
/// <summary> // /// <summary>
/// The maximum number of traits that can be taken in this category. // /// The maximum number of traits that can be taken in this category.
/// </summary> // /// </summary>
[DataField] // [DataField]
public int? MaxTraitPoints; // public int? MaxTraitPoints;
} // }

View File

@ -1,76 +1,76 @@
using Content.Shared.Whitelist; // using Content.Shared.Whitelist; // DeltaV - Traits rework
using Robust.Shared.Prototypes; // using Robust.Shared.Prototypes;
using Content.Shared.Humanoid.Prototypes; // DeltaV - Trait species hiding // using Content.Shared.Humanoid.Prototypes; // DeltaV - Trait species hiding
//
namespace Content.Shared.Traits; // namespace Content.Shared.Traits;
//
/// <summary> // /// <summary>
/// Describes a trait. // /// Describes a trait.
/// </summary> // /// </summary>
[Prototype] // [Prototype]
public sealed partial class TraitPrototype : IPrototype // public sealed partial class TraitPrototype : IPrototype
{ // {
[ViewVariables] // [ViewVariables]
[IdDataField] // [IdDataField]
public string ID { get; private set; } = default!; // public string ID { get; private set; } = default!;
//
/// <summary> // /// <summary>
/// The name of this trait. // /// The name of this trait.
/// </summary> // /// </summary>
[DataField] // [DataField]
public LocId Name { get; private set; } = string.Empty; // public LocId Name { get; private set; } = string.Empty;
//
/// <summary> // /// <summary>
/// The description of this trait. // /// The description of this trait.
/// </summary> // /// </summary>
[DataField] // [DataField]
public LocId? Description { get; private set; } // public LocId? Description { get; private set; }
//
/// <summary> // /// <summary>
/// Don't apply this trait to entities this whitelist IS NOT valid for. // /// Don't apply this trait to entities this whitelist IS NOT valid for.
/// </summary> // /// </summary>
[DataField] // [DataField]
public EntityWhitelist? Whitelist; // public EntityWhitelist? Whitelist;
//
/// <summary> // /// <summary>
/// Don't apply this trait to entities this whitelist IS valid for. (hence, a blacklist) // /// Don't apply this trait to entities this whitelist IS valid for. (hence, a blacklist)
/// </summary> // /// </summary>
[DataField] // [DataField]
public EntityWhitelist? Blacklist; // public EntityWhitelist? Blacklist;
//
/// <summary> // /// <summary>
/// The components that get added to the player, when they pick this trait. // /// The components that get added to the player, when they pick this trait.
/// </summary> // /// </summary>
[DataField] // [DataField]
public ComponentRegistry Components { get; private set; } = default!; // public ComponentRegistry Components { get; private set; } = default!;
//
/// <summary> // /// <summary>
/// DeltaV - Components that get added to the player, overriding any existing instances of the component if they exist. // /// DeltaV - Components that get added to the player, overriding any existing instances of the component if they exist.
/// </summary> // /// </summary>
[DataField] // [DataField]
public ComponentRegistry? OverriddenComponents { get; private set; } // public ComponentRegistry? OverriddenComponents { get; private set; }
//
/// <summary> // /// <summary>
/// Gear that is given to the player, when they pick this trait. // /// Gear that is given to the player, when they pick this trait.
/// </summary> // /// </summary>
[DataField] // [DataField]
public EntProtoId? TraitGear; // public EntProtoId? TraitGear;
//
/// <summary> // /// <summary>
/// Trait Price. If negative number, points will be added. // /// Trait Price. If negative number, points will be added.
/// </summary> // /// </summary>
[DataField] // [DataField]
public int Cost = 0; // public int Cost = 0;
//
/// <summary> // /// <summary>
/// Adds a trait to a category, allowing you to limit the selection of some traits to the settings of that category. // /// Adds a trait to a category, allowing you to limit the selection of some traits to the settings of that category.
/// </summary> // /// </summary>
[DataField] // [DataField]
public ProtoId<TraitCategoryPrototype>? Category; // public ProtoId<TraitCategoryPrototype>? Category;
//
/// <summary> // /// <summary>
/// DeltaV - Hides traits from specific species // /// DeltaV - Hides traits from specific species
/// </summary> // /// </summary>
[DataField] // [DataField]
public HashSet<ProtoId<SpeciesPrototype>> ExcludedSpecies = new(); // public HashSet<ProtoId<SpeciesPrototype>> ExcludedSpecies = new();
} // }

View File

@ -124,6 +124,23 @@ public sealed partial class DCCVars
public static readonly CVarDef<int> YearOffset = public static readonly CVarDef<int> YearOffset =
CVarDef.Create("game.current_year_offset", 550, CVar.SERVERONLY); CVarDef.Create("game.current_year_offset", 550, CVar.SERVERONLY);
/*
* Traits
*/
/// <summary>
/// Maximum number of traits that can be selected globally.
/// </summary>
public static readonly CVarDef<int> MaxTraitCount =
CVarDef.Create("traits.max_count", 10, CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Maximum trait points available to spend.
/// Traits with positive cost consume points, negative cost traits grant points.
/// </summary>
public static readonly CVarDef<int> MaxTraitPoints =
CVarDef.Create("traits.max_points", 15, CVar.SERVER | CVar.REPLICATED);
/* /*
* Feedback webhook * Feedback webhook
*/ */

View File

@ -0,0 +1,60 @@
using JetBrains.Annotations;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Conditions;
/// <summary>
/// Base class for trait conditions. Implementations check if a trait can be applied to a player.
/// </summary>
[ImplicitDataDefinitionForInheritors, UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
public abstract partial class BaseTraitCondition
{
/// <summary>
/// If true, inverts the result of the condition.
/// </summary>
[DataField]
public bool Invert;
/// <summary>
/// Evaluates the condition, applying inversion if configured.
/// </summary>
[PublicAPI]
public bool Evaluate(TraitConditionContext ctx)
{
var result = EvaluateImplementation(ctx);
return result ^ Invert;
}
/// <summary>
/// Generates a human-readable tooltip describing this condition's requirements.
/// </summary>
[PublicAPI]
public abstract string GetTooltip(IPrototypeManager proto, ILocalizationManager loc);
protected abstract bool EvaluateImplementation(TraitConditionContext ctx);
}
/// <summary>
/// Context passed to trait conditions for evaluation.
/// Contains references to the player and relevant systems.
/// </summary>
public sealed class TraitConditionContext
{
public required EntityUid Player { get; init; }
public required ICommonSession? Session { get; init; }
public required IEntityManager EntMan { get; init; }
public required IPrototypeManager Proto { get; init; }
public required IComponentFactory CompFactory { get; init; }
public required ILogManager LogMan { get; init; }
/// <summary>
/// The job ID of the player, if available.
/// </summary>
public string? JobId { get; init; }
/// <summary>
/// The species ID of the player, if available.
/// </summary>
public string? SpeciesId { get; init; }
}

View File

@ -0,0 +1,41 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared._DV.Traits.Conditions;
/// <summary>
/// Condition that checks if the player has a specific component.
/// Use Invert = true to check if the player does NOT have the component.
/// </summary>
public sealed partial class HasCompCondition : BaseTraitCondition
{
/// <summary>
/// The component name to check for (e.g., "Pacifism").
/// </summary>
[DataField(required: true, customTypeSerializer: typeof(ComponentNameSerializer))]
public string Component = string.Empty;
protected override bool EvaluateImplementation(TraitConditionContext ctx)
{
if (string.IsNullOrEmpty(Component))
return false;
try
{
var compType = ctx.CompFactory.GetRegistration(Component).Type;
return ctx.EntMan.HasComponent(ctx.Player, compType);
}
catch (Exception)
{
// Log the actual error instead of silently catching
ctx.LogMan.GetSawmill("traits").Error($"Failed to get component registration for '{Component}'");
return false;
}
}
public override string GetTooltip(IPrototypeManager proto, ILocalizationManager loc)
{
// No tooltip for this condition since we're dealing with comps
return string.Empty;
}
}

View File

@ -0,0 +1,50 @@
using Content.Shared.Roles;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Conditions;
/// <summary>
/// Condition that checks if the player has a specific job.
/// Use Invert = true to check if the player does NOT have the job.
/// </summary>
public sealed partial class HasJobCondition : BaseTraitCondition
{
/// <summary>
/// The job prototype ID to check for.
/// </summary>
[DataField(required: true)]
public ProtoId<JobPrototype> Job = string.Empty;
protected override bool EvaluateImplementation(TraitConditionContext ctx)
{
if (string.IsNullOrEmpty(ctx.JobId))
return false;
return ctx.JobId == Job;
}
public override string GetTooltip(IPrototypeManager proto, ILocalizationManager loc)
{
var jobName = Job.Id;
var jobColor = "#ffffff";
if (proto.TryIndex(Job, out var jobProto))
{
jobName = loc.GetString(jobProto.Name);
// Try to find the job's department color
foreach (var dept in proto.EnumeratePrototypes<DepartmentPrototype>())
{
if (dept.Roles.Contains(Job))
{
jobColor = dept.Color.ToHex();
break;
}
}
}
return Invert
? loc.GetString("trait-condition-job-not", ("job", jobName), ("color", jobColor))
: loc.GetString("trait-condition-job-is", ("job", jobName), ("color", jobColor));
}
}

View File

@ -0,0 +1,45 @@
using Content.Shared.Roles;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Conditions;
/// <summary>
/// Condition that checks if the player's job is in a specific department.
/// Use Invert = true to check if the player is NOT in the department.
/// </summary>
public sealed partial class InDepartmentCondition : BaseTraitCondition
{
/// <summary>
/// The department prototype ID to check for.
/// </summary>
[DataField(required: true)]
public ProtoId<DepartmentPrototype> Department = string.Empty;
protected override bool EvaluateImplementation(TraitConditionContext ctx)
{
if (string.IsNullOrEmpty(ctx.JobId))
return false;
if (!ctx.Proto.TryIndex(Department, out var department))
return false;
return department.Roles.Contains(ctx.JobId);
}
public override string GetTooltip(IPrototypeManager proto, ILocalizationManager loc)
{
var deptName = Department.Id;
var deptColor = "#ffffff";
if (proto.TryIndex(Department, out var deptProto))
{
deptName = loc.GetString($"department-{deptProto.ID}");
deptColor = deptProto.Color.ToHex();
}
return Invert
? loc.GetString("trait-condition-department-not", ("department", deptName), ("color", deptColor))
: loc.GetString("trait-condition-department-is", ("department", deptName), ("color", deptColor));
}
}

View File

@ -0,0 +1,38 @@
using Content.Shared.Humanoid.Prototypes;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Conditions;
/// <summary>
/// Condition that checks if the player is a specific species.
/// Use Invert = true to check if the player is NOT the species.
/// </summary>
public sealed partial class IsSpeciesCondition : BaseTraitCondition
{
/// <summary>
/// The species ID to check for.
/// </summary>
[DataField(required: true)]
public ProtoId<SpeciesPrototype> Species = string.Empty;
protected override bool EvaluateImplementation(TraitConditionContext ctx)
{
if (string.IsNullOrEmpty(ctx.SpeciesId))
return false;
return ctx.SpeciesId == Species;
}
public override string GetTooltip(IPrototypeManager proto, ILocalizationManager loc)
{
var speciesName = Species.Id;
if (proto.TryIndex(Species, out var speciesProto))
{
speciesName = loc.GetString(speciesProto.Name);
}
return Invert
? loc.GetString("trait-condition-species-not", ("species", speciesName))
: loc.GetString("trait-condition-species-is", ("species", speciesName));
}
}

View File

@ -0,0 +1,21 @@
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Effects;
/// <summary>
/// Effect that adds components to the player entity.
/// Components are added without overwriting existing ones.
/// </summary>
public sealed partial class AddCompsEffect : BaseTraitEffect
{
/// <summary>
/// The components to add to the entity.
/// </summary>
[DataField(required: true)]
public ComponentRegistry Components = new();
public override void Apply(TraitEffectContext ctx)
{
ctx.EntMan.AddComponents(ctx.Player, Components, removeExisting: false);
}
}

View File

@ -0,0 +1,31 @@
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Effects;
/// <summary>
/// Base class for trait effects. Implementations apply modifications to an entity when a trait is selected.
/// </summary>
[ImplicitDataDefinitionForInheritors, UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
public abstract partial class BaseTraitEffect
{
/// <summary>
/// Applies the effect to the target entity.
/// </summary>
[PublicAPI]
public abstract void Apply(TraitEffectContext ctx);
}
/// <summary>
/// Context passed to trait effects for application.
/// Contains references to the player entity and relevant systems.
/// </summary>
public sealed class TraitEffectContext
{
public required EntityUid Player { get; init; }
public required IEntityManager EntMan { get; init; }
public required IPrototypeManager Proto { get; init; }
public required IComponentFactory CompFactory { get; init; }
public required ILogManager LogMan { get; init; }
public required TransformComponent Transform { get; init; }
}

View File

@ -0,0 +1,22 @@
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Effects;
/// <summary>
/// Effect that overrides component fields on the player entity.
/// If the component exists, its fields are overwritten with the new values.
/// If it doesn't exist, the component is added.
/// </summary>
public sealed partial class OverrideCompsEffect : BaseTraitEffect
{
/// <summary>
/// The components to add/override on the entity.
/// </summary>
[DataField(required: true)]
public ComponentRegistry Components = new();
public override void Apply(TraitEffectContext ctx)
{
ctx.EntMan.AddComponents(ctx.Player, Components, removeExisting: true);
}
}

View File

@ -0,0 +1,31 @@
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Generic;
namespace Content.Shared._DV.Traits.Effects;
/// <summary>
/// Effect that removes components from the player entity if they exist.
/// </summary>
public sealed partial class RemCompsEffect : BaseTraitEffect
{
/// <summary>
/// The component names to remove from the entity.
/// </summary>
[DataField(required: true, customTypeSerializer: typeof(CustomHashSetSerializer<string, ComponentNameSerializer>))]
public HashSet<string> Components = new();
public override void Apply(TraitEffectContext ctx)
{
foreach (var compName in Components)
{
if (!ctx.CompFactory.TryGetRegistration(compName, out var registration))
{
var sawmill = ctx.LogMan.GetSawmill("traits");
sawmill.Warning($"RemCompsEffect references unknown component: {compName}");
continue;
}
ctx.EntMan.RemoveComponent(ctx.Player, registration.Type);
}
}
}

View File

@ -0,0 +1,23 @@
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits.Effects;
/// <summary>
/// Effect that spawns an item and attempts to place it in the player's hand.
/// If the player cannot hold the item, it is spawned at their feet.
/// </summary>
public sealed partial class SpawnItemInHandEffect : BaseTraitEffect
{
/// <summary>
/// The entity prototype to spawn.
/// </summary>
[DataField(required: true)]
public EntProtoId Item = string.Empty;
public override void Apply(TraitEffectContext ctx)
{
// This effect needs to be applied server-side where we have access to
// SharedHandsSystem. The actual spawning logic is handled by the server TraitSystem.
// This class just holds the data.
}
}

View File

@ -0,0 +1,52 @@
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits;
/// <summary>
/// Prototype for a category of traits.
/// Categories organize traits and can impose their own limits.
/// </summary>
[Prototype]
public sealed partial class TraitCategoryPrototype : IPrototype
{
[IdDataField]
public string ID { get; private set; } = default!;
/// <summary>
/// Localization key for the category's display name.
/// </summary>
[DataField(required: true)]
public LocId Name;
/// <summary>
/// Display order priority. Lower values appear first.
/// </summary>
[DataField]
public int Priority;
/// <summary>
/// Maximum number of traits that can be selected from this category.
/// Null means unlimited (only global limit applies).
/// </summary>
[DataField]
public int? MaxTraits;
/// <summary>
/// Maximum trait points that can be spent in this category.
/// Null means unlimited (only global limit applies).
/// </summary>
[DataField]
public int? MaxPoints;
/// <summary>
/// Color hex for the category header accent.
/// </summary>
[DataField]
public Color AccentColor = Color.FromHex("#4a9eff");
/// <summary>
/// Whether this category starts expanded or collapsed.
/// </summary>
[DataField]
public bool DefaultExpanded = true;
}

View File

@ -0,0 +1,60 @@
using Content.Shared._DV.Traits.Conditions;
using Content.Shared._DV.Traits.Effects;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Traits;
/// <summary>
/// Prototype for a character trait in DeltaV.
/// Traits modify character behavior through condition-checked effects.
/// </summary>
[Prototype]
public sealed partial class TraitPrototype : IPrototype
{
[IdDataField]
public string ID { get; private set; } = default!;
/// <summary>
/// Localization key for the trait's display name.
/// </summary>
[DataField(required: true)]
public LocId Name;
/// <summary>
/// Localization key for the trait's description.
/// </summary>
[DataField(required: true)]
public LocId Description;
/// <summary>
/// The category this trait belongs to.
/// </summary>
[DataField(required: true)]
public ProtoId<TraitCategoryPrototype> Category;
/// <summary>
/// How many trait points this trait costs (positive) or grants (negative).
/// </summary>
[DataField]
public int Cost = 1;
/// <summary>
/// Conditions that must be met for this trait to be selectable and applied.
/// All conditions must pass for the trait to be valid.
/// </summary>
[DataField]
public List<BaseTraitCondition> Conditions = new();
/// <summary>
/// Effects to apply to the entity when this trait is selected.
/// Effects are applied in order.
/// </summary>
[DataField]
public List<BaseTraitEffect> Effects = new();
/// <summary>
/// Other traits that are mutually exclusive with this one.
/// </summary>
[DataField]
public List<ProtoId<TraitPrototype>> Conflicts = new();
}

View File

@ -0,0 +1,4 @@
trait-category-disabilities = Disabilities
trait-category-medical = Medical
trait-category-mental = Mental
trait-category-accents = Accents

View File

@ -0,0 +1,29 @@
## Traits Editor UI
trait-editor-title = Character Traits
trait-editor-points-label = Available Points
trait-editor-search-placeholder = Search traits...
trait-editor-footer-hint = Hover over traits for details
trait-editor-footer-info = Negative costs grant bonus points
## Category suffixes
trait-category-traits = {$selected} / {$max} traits
trait-category-traits-unlimited = {$selected} traits
trait-category-points = ({$selected} / {$max} pts)
## Condition tooltips
trait-conditions-tooltip = [bold]Requirements:[/bold]
{$requirements}
trait-conditions-not-met-tooltip = Requirements not met:
{$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].
## Job conditions
trait-condition-job-is = You must be a [color={$color}]{$job}[/color].
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.

View File

@ -59,6 +59,7 @@ humanoid-profile-editor-no-traits = No traits available
humanoid-profile-editor-trait-count-hint = Points available: [{$current}/{$max}] humanoid-profile-editor-trait-count-hint = Points available: [{$current}/{$max}]
trait-category-disabilities = Disabilities # DeltaV - Custom traits; Moved to _DV to be next to the description
#trait-category-disabilities = Disabilities
trait-category-speech = Speech traits trait-category-speech = Speech traits
trait-category-quirks = Quirks trait-category-quirks = Quirks

View File

@ -1,12 +1,16 @@
- type: traitCategory # DELTA-V
id: Disabilities # Due to the custom trait system, all traits have been moved under \Resources\Prototypes\_DV\Traits
name: trait-category-disabilities # If you want to add a new one, do it there
- type: traitCategory #- type: traitCategory
id: SpeechTraits # id: Disabilities
name: trait-category-speech # name: trait-category-disabilities
maxTraitPoints: 2 #
#- type: traitCategory
- type: traitCategory # id: SpeechTraits
id: Quirks # name: trait-category-speech
name: trait-category-quirks # maxTraitPoints: 2
#
#- type: traitCategory
# id: Quirks
# name: trait-category-quirks

View File

@ -1,104 +1,108 @@
# DELTA-V
# Due to the custom trait system, all traits have been moved under \Resources\Prototypes\_DV\Traits
# If you want to add a new one, do it there
# If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly! # If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly!
#
- type: trait #- type: trait
id: Blindness # id: Blindness
name: trait-blindness-name # name: trait-blindness-name
description: trait-blindness-desc # description: trait-blindness-desc
traitGear: WhiteCane # traitGear: WhiteCane
category: Disabilities # category: Disabilities
whitelist: # whitelist:
components: # components:
- Blindable # - Blindable
components: # components:
- type: PermanentBlindness # - type: PermanentBlindness
#
- type: trait #- type: trait
id: PoorVision # id: PoorVision
name: trait-poor-vision-name # name: trait-poor-vision-name
description: trait-poor-vision-desc # description: trait-poor-vision-desc
traitGear: ClothingEyesGlasses # traitGear: ClothingEyesGlasses
category: Disabilities # category: Disabilities
whitelist: # whitelist:
components: # components:
- Blindable # - Blindable
components: # components:
- type: PermanentBlindness # - type: PermanentBlindness
blindness: 4 # blindness: 4
#
- type: trait #- type: trait
id: Narcolepsy # id: Narcolepsy
name: trait-narcolepsy-name # name: trait-narcolepsy-name
description: trait-narcolepsy-desc # description: trait-narcolepsy-desc
category: Disabilities # category: Disabilities
components: # components:
- type: Narcolepsy # - type: Narcolepsy
maxTimeBetweenIncidents: 600 # maxTimeBetweenIncidents: 600
minTimeBetweenIncidents: 300 # minTimeBetweenIncidents: 300
maxDurationOfIncident: 30 # maxDurationOfIncident: 30
minDurationOfIncident: 10 # minDurationOfIncident: 10
#
- type: trait #- type: trait
id: Unrevivable # id: Unrevivable
name: trait-unrevivable-name # name: trait-unrevivable-name
description: trait-unrevivable-desc # description: trait-unrevivable-desc
category: Disabilities # category: Disabilities
components: # components:
- type: Unrevivable # - type: Unrevivable
cloneable: true # cloneable: true
#
- type: trait #- type: trait
id: Monochromacy # id: Monochromacy
name: trait-monochromacy-name # name: trait-monochromacy-name
description: trait-monochromacy-desc # description: trait-monochromacy-desc
category: Disabilities # category: Disabilities
components: # components:
- type: BlackAndWhiteOverlay # - type: BlackAndWhiteOverlay
#
- type: trait #- type: trait
id: Muted # id: Muted
name: trait-muted-name # name: trait-muted-name
description: trait-muted-desc # description: trait-muted-desc
category: Disabilities # category: Disabilities
blacklist: # blacklist:
components: # components:
- BorgChassis # - BorgChassis
components: # components:
- type: Muted # - type: Muted
#
- type: trait #- type: trait
id: Paracusia # id: Paracusia
name: trait-paracusia-name # name: trait-paracusia-name
description: trait-paracusia-desc # description: trait-paracusia-desc
category: Disabilities # category: Disabilities
components: # components:
- type: Paracusia # - type: Paracusia
minTimeBetweenIncidents: 0.1 # minTimeBetweenIncidents: 0.1
maxTimeBetweenIncidents: 300 # maxTimeBetweenIncidents: 300
maxSoundDistance: 7 # maxSoundDistance: 7
sounds: # sounds:
collection: Paracusia # collection: Paracusia
#
- type: trait #- type: trait
id: PainNumbness # id: PainNumbness
name: trait-painnumbness-name # name: trait-painnumbness-name
description: trait-painnumbness-desc # description: trait-painnumbness-desc
category: Disabilities # category: Disabilities
components: # components:
- type: PainNumbness # - type: PainNumbness
#
- type: trait #- type: trait
id: ImpairedMobility # id: ImpairedMobility
name: trait-impaired-mobility-name # name: trait-impaired-mobility-name
description: trait-impaired-mobility-desc # description: trait-impaired-mobility-desc
traitGear: OffsetCane # traitGear: OffsetCane
category: Disabilities # category: Disabilities
components: # components:
- type: ImpairedMobility # - type: ImpairedMobility
#
- type: trait #- type: trait
id: Hemophilia # id: Hemophilia
name: trait-hemophilia-name # name: trait-hemophilia-name
description: trait-hemophilia-desc # description: trait-hemophilia-desc
category: Disabilities # category: Disabilities
components: # components:
- type: Hemophilia # - type: Hemophilia

View File

@ -1,26 +1,30 @@
# If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly! # DELTA-V
# Due to the custom trait system, all traits have been moved under \Resources\Prototypes\_DV\Traits
# If you want to add a new one, do it there
- type: trait ## If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly!
id: Pacifist #
name: trait-pacifist-name #- type: trait
description: trait-pacifist-desc # id: Pacifist
category: Quirks # name: trait-pacifist-name
components: # description: trait-pacifist-desc
- type: Pacified # category: Quirks
# components:
- type: trait # - type: Pacified
id: LightweightDrunk #
name: trait-lightweight-name #- type: trait
description: trait-lightweight-desc # id: LightweightDrunk
category: Quirks # name: trait-lightweight-name
components: # description: trait-lightweight-desc
- type: LightweightDrunk # category: Quirks
boozeStrengthMultiplier: 2 # components:
# - type: LightweightDrunk
- type: trait # boozeStrengthMultiplier: 2
id: Snoring #
name: trait-snoring-name #- type: trait
description: trait-snoring-desc # id: Snoring
category: Quirks # name: trait-snoring-name
components: # description: trait-snoring-desc
- type: Snoring # category: Quirks
# components:
# - type: Snoring

View File

@ -1,91 +1,95 @@
# If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly! # DELTA-V
# Due to the custom trait system, all traits have been moved under \Resources\Prototypes\_DV\Traits
# Free # If you want to add a new one, do it there
- type: trait
id: Accentless
name: trait-accentless-name
description: trait-accentless-desc
category: SpeechTraits
cost: 0 # Delta-V change- removed cost for accentless to 0, as it said it was supposed to be free
components:
- type: Accentless
removes:
- type: LizardAccent
- type: MothAccent
- type: ReplacementAccent
accent: dwarf
- type: trait
id: Liar
name: trait-liar-name
description: trait-liar-desc
category: SpeechTraits
cost: 0 # Delta-V change- removed cost for liar to 0, as was discussed
components:
- type: ReplacementAccent
accent: liar
- type: trait
id: SocialAnxiety
name: trait-socialanxiety-name
description: trait-socialanxiety-desc
category: SpeechTraits
cost: 0 # Delta-V change- lowered cost to 0 to allow for more versatility with accents
components:
- type: StutteringAccent
matchRandomProb: 0.1
fourRandomProb: 0
threeRandomProb: 0
cutRandomProb: 0
- type: trait
id: FrontalLisp
name: trait-frontal-lisp-name
description: trait-frontal-lisp-desc
category: SpeechTraits
cost: 0 # Delta-V change- lowered cost to 0 to allow for more versatility with accents
components:
- type: FrontalLisp
# 2 Cost
- type: trait
id: SouthernAccent
name: trait-southern-name
description: trait-southern-desc
category: SpeechTraits
cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
components:
- type: SouthernAccent
- type: trait
id: PirateAccent
name: trait-pirate-accent-name
description: trait-pirate-accent-desc
category: SpeechTraits
cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
components:
- type: PirateAccent
- type: trait
id: CowboyAccent
name: trait-cowboy-name
description: trait-cowboy-desc
category: SpeechTraits
cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
components:
- type: ReplacementAccent
accent: cowboy
- type: trait
id: ItalianAccent
name: trait-italian-name
description: trait-italian-desc
category: SpeechTraits
cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
components:
- type: ReplacementAccent
accent: italian
## If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly!
#
## Free
#
#- type: trait
# id: Accentless
# name: trait-accentless-name
# description: trait-accentless-desc
# category: SpeechTraits
# cost: 0 # Delta-V change- removed cost for accentless to 0, as it said it was supposed to be free
# components:
# - type: Accentless
# removes:
# - type: LizardAccent
# - type: MothAccent
# - type: ReplacementAccent
# accent: dwarf
#
#- type: trait
# id: Liar
# name: trait-liar-name
# description: trait-liar-desc
# category: SpeechTraits
# cost: 0 # Delta-V change- removed cost for liar to 0, as was discussed
# components:
# - type: ReplacementAccent
# accent: liar
#
#- type: trait
# id: SocialAnxiety
# name: trait-socialanxiety-name
# description: trait-socialanxiety-desc
# category: SpeechTraits
# cost: 0 # Delta-V change- lowered cost to 0 to allow for more versatility with accents
# components:
# - type: StutteringAccent
# matchRandomProb: 0.1
# fourRandomProb: 0
# threeRandomProb: 0
# cutRandomProb: 0
#
#- type: trait
# id: FrontalLisp
# name: trait-frontal-lisp-name
# description: trait-frontal-lisp-desc
# category: SpeechTraits
# cost: 0 # Delta-V change- lowered cost to 0 to allow for more versatility with accents
# components:
# - type: FrontalLisp
#
## 2 Cost
#
#- type: trait
# id: SouthernAccent
# name: trait-southern-name
# description: trait-southern-desc
# category: SpeechTraits
# cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
# components:
# - type: SouthernAccent
#
#- type: trait
# id: PirateAccent
# name: trait-pirate-accent-name
# description: trait-pirate-accent-desc
# category: SpeechTraits
# cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
# components:
# - type: PirateAccent
#
#- type: trait
# id: CowboyAccent
# name: trait-cowboy-name
# description: trait-cowboy-desc
# category: SpeechTraits
# cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
# components:
# - type: ReplacementAccent
# accent: cowboy
#
#- type: trait
# id: ItalianAccent
# name: trait-italian-name
# description: trait-italian-desc
# category: SpeechTraits
# cost: 2 # Delta-V change- weighted up accents to 2 as discussed for accent stacking being off
# components:
# - type: ReplacementAccent
# accent: italian
#
#

View File

@ -1,15 +1,19 @@
- type: trait # DELTA-V
category: Quirks # Due to the custom trait system, all traits have been moved under \Resources\Prototypes\_DV\Traits
id: Synthetic # If you want to add a new one, do it there
name: trait-synth-name
description: trait-synth-desc #- type: trait
# Begin DeltaV Additions - blacklist IPCs # category: Quirks
blacklist: # id: Synthetic
components: # name: trait-synth-name
- Silicon # description: trait-synth-desc
- BorgChassis # # Begin DeltaV Additions - blacklist IPCs
excludedSpecies: # blacklist:
- IPC # components:
# End DeltaV Additions - blacklist IPCs # - Silicon
components: # - BorgChassis
- type: Synth # excludedSpecies:
# - IPC
# # End DeltaV Additions - blacklist IPCs
# components:
# - type: Synth

View File

@ -1,15 +0,0 @@
- type: trait
id: UltraVision
name: trait-ultravision-name
description: trait-ultravision-desc
category: Disabilities
components:
- type: UltraVision
- type: trait
id: DogVision
name: trait-deuteranopia-name
description: trait-deuteranopia-desc
category: Disabilities
components:
- type: DogVision

View File

@ -1,21 +0,0 @@
- type: trait
id: ArmAmputeeL
name: trait-amputee-left-arm-name
description: trait-amputee-left-arm-desc
category: Amputee
cost: 1
components:
- type: Amputee
removeBodyPart: Arm
partSymmetry: Left
- type: trait
id: ArmAmputeeR
name: trait-amputee-right-arm-name
description: trait-amputee-right-arm-desc
category: Amputee
cost: 1
components:
- type: Amputee
removeBodyPart: Arm
partSymmetry: Right

View File

@ -1,4 +0,0 @@
- type: traitCategory
id: Amputee
name: trait-category-amputee
maxTraitPoints: 1

View File

@ -1,54 +1,132 @@
- type: trait ## If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly!
id: Addicted
name: trait-addicted-name
description: trait-addicted-desc
category: Disabilities
components:
- type: Addicted
- type: trait - type: trait
id: InPain id: Blindness
name: trait-inpain-name name: trait-blindness-name
description: trait-inpain-desc description: trait-blindness-desc
traitGear: PillCanisterSoretizone
category: Disabilities category: Disabilities
components: conditions:
- type: Pain - !type:HasCompCondition
component: Blindable
effects:
- !type:AddCompsEffect
components:
- type: PermanentBlindness
- !type:SpawnItemInHandEffect
item: WhiteCane
- type: trait - type: trait
id: Unborgable id: PoorVision
name: trait-unborgable-name name: trait-poor-vision-name
description: trait-unborgable-desc description: trait-poor-vision-desc
category: Disabilities category: Disabilities
components: conditions:
- type: Unborgable # Automatically gets moved to the brain - !type:HasCompCondition
component: Blindable
effects:
- !type:AddCompsEffect
components:
- type: PermanentBlindness
blindness: 4
- !type:SpawnItemInHandEffect
item: ClothingEyesGlasses
- type: trait - type: trait
id: Depression id: Monochromacy
name: trait-depression-name name: trait-monochromacy-name
description: trait-depression-desc description: trait-monochromacy-desc
traitGear: PillCanisterNeurozenium
category: Disabilities category: Disabilities
components: [] effects:
- !type:AddCompsEffect
components:
- type: BlackAndWhiteOverlay
- type: trait
id: Deuteranopia
name: trait-deuteranopia-name
description: trait-deuteranopia-desc
category: Disabilities
effects:
- !type:AddCompsEffect
components:
- type: DogVision
- type: trait
id: UltraVision
name: trait-ultravision-name
description: trait-ultravision-desc
category: Disabilities
effects:
- !type:AddCompsEffect
components:
- type: UltraVision
- type: trait
id: ImpairedMobility
name: trait-impaired-mobility-name
description: trait-impaired-mobility-desc
category: Disabilities
effects:
- !type:AddCompsEffect
components:
- type: ImpairedMobility
- !type:SpawnItemInHandEffect
item: OffsetCane
- type: trait
id: ArmAmputeeLeft
name: trait-amputee-left-arm-name
description: trait-amputee-left-arm-desc
category: Disabilities
conflicts:
- ArmAmputeeRight # TODO: AmputeeComponent doesn't stack, should be an effect instead
effects:
- !type:AddCompsEffect
components:
- type: Amputee
removeBodyPart: Arm
partSymmetry: Left
- type: trait
id: ArmAmputeeRight
name: trait-amputee-right-arm-name
description: trait-amputee-right-arm-desc
category: Disabilities
conflicts:
- ArmAmputeeLeft
effects:
- !type:AddCompsEffect
components:
- type: Amputee
removeBodyPart: Arm
partSymmetry: Right
- type: trait - type: trait
id: Dysgraphia id: Dysgraphia
name: trait-dysgraphia-name name: trait-dysgraphia-name
description: trait-dysgraphia-desc description: trait-dysgraphia-desc
category: Disabilities category: Disabilities
components: effects:
- type: BlockWriting - !type:AddCompsEffect
components:
- type: BlockWriting
- type: trait - type: trait
id: Redshirt id: PainNumbness
name: trait-redshirt-name name: trait-painnumbness-name
description: trait-redshirt-desc description: trait-painnumbness-desc
category: Disabilities category: Disabilities
components: effects:
- type: Redshirt - !type:AddCompsEffect
overriddenComponents: components:
- type: MobThresholds - type: PainNumbness
thresholds:
0: Alive - type: trait
99.9: Critical id: Hemophilia
100: Dead name: trait-hemophilia-name
description: trait-hemophilia-desc
category: Disabilities
effects:
- !type:AddCompsEffect
components:
- type: Hemophilia

View File

@ -0,0 +1,107 @@
## If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly!
- type: trait
id: Narcolepsy
name: trait-narcolepsy-name
description: trait-narcolepsy-desc
category: Medical
effects:
- !type:AddCompsEffect
components:
- type: Narcolepsy
maxTimeBetweenIncidents: 600
minTimeBetweenIncidents: 300
maxDurationOfIncident: 30
minDurationOfIncident: 10
- type: trait
id: InPain
name: trait-inpain-name
description: trait-inpain-desc
category: Medical
effects:
- !type:AddCompsEffect
components:
- type: Pain
- !type:SpawnItemInHandEffect
item: PillCanisterSoretizone
- type: trait
id: LightweightDrunk
name: trait-lightweight-name
description: trait-lightweight-desc
category: Medical
effects:
- !type:AddCompsEffect
components:
- type: LightweightDrunk
boozeStrengthMultiplier: 2
- type: trait
id: Unrevivable
name: trait-unrevivable-name
description: trait-unrevivable-desc
category: Medical
effects:
- !type:AddCompsEffect
components:
- type: Unrevivable
cloneable: true
- type: trait
id: Uncloneable
name: trait-uncloneable-name
description: trait-uncloneable-desc
category: Medical
effects:
- !type:AddCompsEffect
components:
- type: Unrevivable
cloneable: false
- type: trait
id: Unborgable
name: trait-unborgable-name
description: trait-unborgable-desc
category: Medical
effects:
- !type:AddCompsEffect
components:
- type: Unborgable
- type: trait
id: Redshirt
name: trait-redshirt-name
description: trait-redshirt-desc
category: Medical
effects:
- !type:AddCompsEffect
components:
- type: Redshirt
- !type:OverrideCompsEffect
components:
- type: MobThresholds
thresholds:
0: Alive
99.9: Critical
100: Dead
- type: trait
id: Synthetic
name: trait-synth-name
description: trait-synth-desc
category: Medical
conditions:
- !type:HasCompCondition
component: Silicon
invert: true
- !type:HasCompCondition
component: BorgChassis
invert: true
- !type:IsSpeciesCondition
species: IPC
invert: true
effects:
- !type:AddCompsEffect
components:
- type: Synth

View File

@ -0,0 +1,56 @@
## If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly!
- type: trait
id: Paracusia
name: trait-paracusia-name
description: trait-paracusia-desc
category: Mental
effects:
- !type:AddCompsEffect
components:
- type: Paracusia
minTimeBetweenIncidents: 0.1
maxTimeBetweenIncidents: 300
maxSoundDistance: 7
sounds:
collection: Paracusia
- type: trait
id: Depression
name: trait-depression-name
description: trait-depression-desc
category: Mental
effects:
- !type:SpawnItemInHandEffect
item: PillCanisterNeurozenium
- type: trait
id: Addicted
name: trait-addicted-name
description: trait-addicted-desc
category: Mental
effects:
- !type:AddCompsEffect
components:
- type: Addicted
- type: trait
id: Liar
name: trait-liar-name
description: trait-liar-desc
category: Mental
effects:
- !type:AddCompsEffect
components:
- type: ReplacementAccent
accent: liar
- type: trait
id: Pacifist
name: trait-pacifist-name
description: trait-pacifist-desc
category: Mental
effects:
- !type:AddCompsEffect
components:
- type: Pacified

View File

@ -1,47 +0,0 @@
- type: trait
id: ScottishAccent
name: trait-scottish-accent-name
description: trait-scottish-accent-desc
traitGear: BagpipeInstrument
category: SpeechTraits
cost: 2 # Changed weight to 2 in accordance with other accents
components:
- type: ScottishAccent
# "New" Accents for more character variety
- type: trait
id: FrenchAccent
name: trait-french-accent-name
description: trait-french-accent-desc
category: SpeechTraits
cost: 2 # Changed weight to 2 in accordance with other accents
components:
- type: FrenchAccent
- type: trait
id: SpanishAccent
name: trait-spanish-accent-name
description: trait-spanish-accent-desc
category: SpeechTraits
cost: 2 # Changed weight to 2 in accordance with other accents
components:
- type: SpanishAccent
- type: trait
id: MobsterAccent
name: trait-mobster-accent-name
description: trait-mobster-accent-desc
category: SpeechTraits
cost: 2 # Changed weight to 2 in accordance with other accents
components:
- type: MobsterAccent
- type: trait
id: IrishAccent
name: trait-irish-accent-name
description: trait-irish-accent-desc
category: SpeechTraits
cost: 2 # Changed weight to 2 in accordance with other accents
components:
- type: IrishAccent

View File

@ -1,11 +1,174 @@
## If you add a new trait, make sure to add the corresponding component to the whitelist in \Resources\Prototypes\Entities\Mobs\Player\clone.yml so it gets copied to clones correctly!
- type: trait
id: PirateAccent
name: trait-pirate-accent-name
description: trait-pirate-accent-desc
category: Accents
cost: 2
effects:
- !type:AddCompsEffect
components:
- type: PirateAccent
- type: trait
id: SouthernAccent
name: trait-southern-name
description: trait-southern-desc
category: Accents
cost: 2
effects:
- !type:AddCompsEffect
components:
- type: SouthernAccent
- type: trait
id: CowboyAccent
name: trait-cowboy-name
description: trait-cowboy-desc
category: Accents
cost: 2
effects:
- !type:AddCompsEffect
components:
- type: ReplacementAccent
accent: cowboy
- type: trait
id: ItalianAccent
name: trait-italian-name
description: trait-italian-desc
category: Accents
cost: 2
effects:
- !type:AddCompsEffect
components:
- type: ReplacementAccent
accent: italian
- type: trait
id: Muted
name: trait-muted-name
description: trait-muted-desc
category: Accents
cost: 2
conditions:
- !type:HasCompCondition
component: BorgChassis
invert: true
effects:
- !type:AddCompsEffect
components:
- type: Muted
- type: trait
id: FrenchAccent
name: trait-french-accent-name
description: trait-french-accent-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: FrenchAccent
- type: trait
id: SpanishAccent
name: trait-spanish-accent-name
description: trait-spanish-accent-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: SpanishAccent
- type: trait
id: IrishAccent
name: trait-irish-accent-name
description: trait-irish-accent-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: IrishAccent
- type: trait
id: MobsterAccent
name: trait-mobster-accent-name
description: trait-mobster-accent-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: MobsterAccent
- type: trait
id: ScottishAccent
name: trait-scottish-accent-name
description: trait-scottish-accent-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: ScottishAccent
- type: trait
id: Accentless
name: trait-accentless-name
description: trait-accentless-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: Accentless
removes:
- type: LizardAccent
- type: MothAccent
- type: ReplacementAccent
accent: dwarf
- type: trait
id: SocialAnxiety
name: trait-socialanxiety-name
description: trait-socialanxiety-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: StutteringAccent
matchRandomProb: 0.1
fourRandomProb: 0
threeRandomProb: 0
cutRandomProb: 0
- type: trait
id: FrontalLisp
name: trait-frontal-lisp-name
description: trait-frontal-lisp-desc
category: Accents
cost: 0
effects:
- !type:AddCompsEffect
components:
- type: FrontalLisp
- type: trait - type: trait
id: Hushed id: Hushed
name: trait-hushed-name name: trait-hushed-name
description: trait-hushed-desc description: trait-hushed-desc
category: SpeechTraits category: Accents
cost: 0 cost: 0
blacklist: conditions:
- !type:HasCompCondition
component: BorgChassis
invert: true
effects:
- !type:AddCompsEffect
components: components:
- BorgChassis - type: Hushed
components:
- type: Hushed

View File

@ -0,0 +1,25 @@
- type: traitCategory
id: Disabilities
name: trait-category-disabilities
priority: 0
accentColor: "#64748b" # Slate / muted gray
- type: traitCategory
id: Mental
name: trait-category-mental
priority: 20
accentColor: "#a855f7" # Purple
- type: traitCategory
id: Accents
name: trait-category-accents
priority: 30
maxTraits: 3
maxPoints: 2
accentColor: "#38bdf8" # Light blue
- type: traitCategory
id: Medical
name: trait-category-medical
priority: 40
accentColor: "#ef4444" # Red