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();
}
if (obj.WasModified<TraitPrototype>())
{
_profileEditor.RefreshTraits();
}
// if (obj.WasModified<TraitPrototype>()) // DeltaV - Refreshed in TraitsTab
// {
// _profileEditor.RefreshTraits();
// }
}
}

View File

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

View File

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

View File

@ -19,22 +19,24 @@ public sealed partial class TraitPreferenceSelector : Control
public event Action<bool>? PreferenceChanged;
public TraitPreferenceSelector(TraitPrototype trait)
{
RobustXamlLoader.Load(this);
var text = trait.Cost != 0 ? $"[{trait.Cost}] " : "";
text += Loc.GetString(trait.Name);
Cost = trait.Cost;
Checkbox.Text = text;
Checkbox.OnToggled += OnCheckBoxToggled;
if (trait.Description is { } desc)
{
Checkbox.ToolTip = Loc.GetString(desc);
}
}
// 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
// public TraitPreferenceSelector(TraitPrototype trait)
// {
// RobustXamlLoader.Load(this);
//
// var text = trait.Cost != 0 ? $"[{trait.Cost}] " : "";
// text += Loc.GetString(trait.Name);
//
// Cost = trait.Cost;
// Checkbox.Text = text;
// Checkbox.OnToggled += OnCheckBoxToggled;
//
// if (trait.Description is { } desc)
// {
// Checkbox.ToolTip = Loc.GetString(desc);
// }
// }
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 System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Traits.Assorted; // DV
namespace Content.Server.Cloning;

View File

@ -25,6 +25,7 @@ using Robust.Shared.Utility;
using Content.Server._CD.Records; // CD - Character Records
using Content.Shared._CD.Records; // CD - Character Records
using Content.Shared._DV.Tips; // DV - Tips
using Content.Shared._DV.Traits; // DV - Traits
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.Hands.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Roles;
using Content.Shared.Traits;
using Content.Shared.Whitelist;
using Robust.Shared.Prototypes;
namespace Content.Server.Traits;
public sealed class TraitSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly SharedHandsSystem _sharedHandsSystem = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete);
}
// When the player is spawned in, add all trait components selected during character creation
private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent args)
{
// Check if player's job allows to apply traits
if (args.JobId == null ||
!_prototypeManager.Resolve<JobPrototype>(args.JobId, out var protoJob) ||
!protoJob.ApplyTraits)
{
return;
}
foreach (var traitId in args.Profile.TraitPreferences)
{
if (!_prototypeManager.TryIndex<TraitPrototype>(traitId, out var traitPrototype))
{
Log.Error($"No trait found with ID {traitId}!");
return;
}
if (_whitelistSystem.IsWhitelistFail(traitPrototype.Whitelist, args.Mob) ||
_whitelistSystem.IsBlacklistPass(traitPrototype.Blacklist, args.Mob))
continue;
// Add all components required by the prototype
EntityManager.AddComponents(args.Mob, traitPrototype.Components, false);
// Begin DeltaV - Add overridden components
if(traitPrototype.OverriddenComponents != null)
EntityManager.AddComponents(args.Mob, traitPrototype.OverriddenComponents, true);
// End DeltaV
// Add item required by the trait
if (traitPrototype.TraitGear == null)
continue;
if (!TryComp(args.Mob, out HandsComponent? handsComponent))
continue;
var coords = Transform(args.Mob).Coordinates;
var inhandEntity = Spawn(traitPrototype.TraitGear, coords);
_sharedHandsSystem.TryPickup(args.Mob,
inhandEntity,
checkActionBlocker: false,
handsComp: handsComponent);
}
}
}
// using Content.Shared.GameTicking; // DeltaV - Traits rework
// using Content.Shared.Hands.Components;
// using Content.Shared.Hands.EntitySystems;
// using Content.Shared.Roles;
// using Content.Shared.Traits;
// using Content.Shared.Whitelist;
// using Robust.Shared.Prototypes;
//
// namespace Content.Server.Traits;
//
// public sealed class TraitSystem : EntitySystem
// {
// [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
// [Dependency] private readonly SharedHandsSystem _sharedHandsSystem = default!;
// [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
//
// public override void Initialize()
// {
// base.Initialize();
//
// SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete);
// }
//
// // When the player is spawned in, add all trait components selected during character creation
// private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent args)
// {
// // Check if player's job allows to apply traits
// if (args.JobId == null ||
// !_prototypeManager.Resolve<JobPrototype>(args.JobId, out var protoJob) ||
// !protoJob.ApplyTraits)
// {
// return;
// }
//
// foreach (var traitId in args.Profile.TraitPreferences)
// {
// if (!_prototypeManager.TryIndex<TraitPrototype>(traitId, out var traitPrototype))
// {
// Log.Error($"No trait found with ID {traitId}!");
// return;
// }
//
// if (_whitelistSystem.IsWhitelistFail(traitPrototype.Whitelist, args.Mob) ||
// _whitelistSystem.IsBlacklistPass(traitPrototype.Blacklist, args.Mob))
// continue;
//
// // Add all components required by the prototype
// EntityManager.AddComponents(args.Mob, traitPrototype.Components, false);
//
// // Begin DeltaV - Add overridden components
// if(traitPrototype.OverriddenComponents != null)
// EntityManager.AddComponents(args.Mob, traitPrototype.OverriddenComponents, true);
// // End DeltaV
//
// // Add item required by the trait
// if (traitPrototype.TraitGear == null)
// continue;
//
// if (!TryComp(args.Mob, out HandsComponent? handsComponent))
// continue;
//
// var coords = Transform(args.Mob).Coordinates;
// var inhandEntity = Spawn(traitPrototype.TraitGear, coords);
// _sharedHandsSystem.TryPickup(args.Mob,
// inhandEntity,
// checkActionBlocker: false,
// 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.Utility;
using Content.Shared._CD.Records; // CD - Character Records
using Content.Shared._DV.Traits; // DeltaV - Traits rework
namespace Content.Shared.Preferences
{
@ -444,12 +445,12 @@ namespace Content.Shared.Preferences
// Category not found so dump it.
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);
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)
{
@ -470,7 +471,7 @@ namespace Content.Shared.Preferences
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);
}
@ -724,11 +725,11 @@ namespace Content.Shared.Preferences
continue;
// Always valid.
if (traitProto.Category == null)
{
result.Add(trait);
continue;
}
// if (traitProto.Category == null) // DeltaV 13/01/26 - Traits rework
// {
// result.Add(trait);
// continue;
// }
// No category so dump it.
if (!protoManager.Resolve(traitProto.Category, out var category))
@ -738,7 +739,7 @@ namespace Content.Shared.Preferences
existing += traitProto.Cost;
// Too expensive.
if (existing > category.MaxTraitPoints)
if (existing > category.MaxPoints) // DeltaV 13/01/26 - Traits: Was MaxTraitPoints
continue;
groups[category.ID] = existing;

View File

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

View File

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

View File

@ -124,6 +124,23 @@ public sealed partial class DCCVars
public static readonly CVarDef<int> YearOffset =
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
*/

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}]
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-quirks = Quirks

View File

@ -1,12 +1,16 @@
- type: traitCategory
id: Disabilities
name: trait-category-disabilities
# 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: traitCategory
id: SpeechTraits
name: trait-category-speech
maxTraitPoints: 2
- type: traitCategory
id: Quirks
name: trait-category-quirks
#- type: traitCategory
# id: Disabilities
# name: trait-category-disabilities
#
#- type: traitCategory
# id: SpeechTraits
# name: trait-category-speech
# 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!
- type: trait
id: Blindness
name: trait-blindness-name
description: trait-blindness-desc
traitGear: WhiteCane
category: Disabilities
whitelist:
components:
- Blindable
components:
- type: PermanentBlindness
- type: trait
id: PoorVision
name: trait-poor-vision-name
description: trait-poor-vision-desc
traitGear: ClothingEyesGlasses
category: Disabilities
whitelist:
components:
- Blindable
components:
- type: PermanentBlindness
blindness: 4
- type: trait
id: Narcolepsy
name: trait-narcolepsy-name
description: trait-narcolepsy-desc
category: Disabilities
components:
- type: Narcolepsy
maxTimeBetweenIncidents: 600
minTimeBetweenIncidents: 300
maxDurationOfIncident: 30
minDurationOfIncident: 10
- type: trait
id: Unrevivable
name: trait-unrevivable-name
description: trait-unrevivable-desc
category: Disabilities
components:
- type: Unrevivable
cloneable: true
- type: trait
id: Monochromacy
name: trait-monochromacy-name
description: trait-monochromacy-desc
category: Disabilities
components:
- type: BlackAndWhiteOverlay
- type: trait
id: Muted
name: trait-muted-name
description: trait-muted-desc
category: Disabilities
blacklist:
components:
- BorgChassis
components:
- type: Muted
- type: trait
id: Paracusia
name: trait-paracusia-name
description: trait-paracusia-desc
category: Disabilities
components:
- type: Paracusia
minTimeBetweenIncidents: 0.1
maxTimeBetweenIncidents: 300
maxSoundDistance: 7
sounds:
collection: Paracusia
- type: trait
id: PainNumbness
name: trait-painnumbness-name
description: trait-painnumbness-desc
category: Disabilities
components:
- type: PainNumbness
- type: trait
id: ImpairedMobility
name: trait-impaired-mobility-name
description: trait-impaired-mobility-desc
traitGear: OffsetCane
category: Disabilities
components:
- type: ImpairedMobility
- type: trait
id: Hemophilia
name: trait-hemophilia-name
description: trait-hemophilia-desc
category: Disabilities
components:
- type: Hemophilia
#
#- type: trait
# id: Blindness
# name: trait-blindness-name
# description: trait-blindness-desc
# traitGear: WhiteCane
# category: Disabilities
# whitelist:
# components:
# - Blindable
# components:
# - type: PermanentBlindness
#
#- type: trait
# id: PoorVision
# name: trait-poor-vision-name
# description: trait-poor-vision-desc
# traitGear: ClothingEyesGlasses
# category: Disabilities
# whitelist:
# components:
# - Blindable
# components:
# - type: PermanentBlindness
# blindness: 4
#
#- type: trait
# id: Narcolepsy
# name: trait-narcolepsy-name
# description: trait-narcolepsy-desc
# category: Disabilities
# components:
# - type: Narcolepsy
# maxTimeBetweenIncidents: 600
# minTimeBetweenIncidents: 300
# maxDurationOfIncident: 30
# minDurationOfIncident: 10
#
#- type: trait
# id: Unrevivable
# name: trait-unrevivable-name
# description: trait-unrevivable-desc
# category: Disabilities
# components:
# - type: Unrevivable
# cloneable: true
#
#- type: trait
# id: Monochromacy
# name: trait-monochromacy-name
# description: trait-monochromacy-desc
# category: Disabilities
# components:
# - type: BlackAndWhiteOverlay
#
#- type: trait
# id: Muted
# name: trait-muted-name
# description: trait-muted-desc
# category: Disabilities
# blacklist:
# components:
# - BorgChassis
# components:
# - type: Muted
#
#- type: trait
# id: Paracusia
# name: trait-paracusia-name
# description: trait-paracusia-desc
# category: Disabilities
# components:
# - type: Paracusia
# minTimeBetweenIncidents: 0.1
# maxTimeBetweenIncidents: 300
# maxSoundDistance: 7
# sounds:
# collection: Paracusia
#
#- type: trait
# id: PainNumbness
# name: trait-painnumbness-name
# description: trait-painnumbness-desc
# category: Disabilities
# components:
# - type: PainNumbness
#
#- type: trait
# id: ImpairedMobility
# name: trait-impaired-mobility-name
# description: trait-impaired-mobility-desc
# traitGear: OffsetCane
# category: Disabilities
# components:
# - type: ImpairedMobility
#
#- type: trait
# id: Hemophilia
# name: trait-hemophilia-name
# description: trait-hemophilia-desc
# category: Disabilities
# components:
# - 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
id: Pacifist
name: trait-pacifist-name
description: trait-pacifist-desc
category: Quirks
components:
- type: Pacified
- type: trait
id: LightweightDrunk
name: trait-lightweight-name
description: trait-lightweight-desc
category: Quirks
components:
- type: LightweightDrunk
boozeStrengthMultiplier: 2
- type: trait
id: Snoring
name: trait-snoring-name
description: trait-snoring-desc
category: Quirks
components:
- type: Snoring
## 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: Pacifist
# name: trait-pacifist-name
# description: trait-pacifist-desc
# category: Quirks
# components:
# - type: Pacified
#
#- type: trait
# id: LightweightDrunk
# name: trait-lightweight-name
# description: trait-lightweight-desc
# category: Quirks
# components:
# - type: LightweightDrunk
# boozeStrengthMultiplier: 2
#
#- type: trait
# id: Snoring
# name: trait-snoring-name
# description: trait-snoring-desc
# 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!
# 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
# 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!
#
## 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
category: Quirks
id: Synthetic
name: trait-synth-name
description: trait-synth-desc
# Begin DeltaV Additions - blacklist IPCs
blacklist:
components:
- Silicon
- BorgChassis
excludedSpecies:
- IPC
# End DeltaV Additions - blacklist IPCs
components:
- type: Synth
# 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
# category: Quirks
# id: Synthetic
# name: trait-synth-name
# description: trait-synth-desc
# # Begin DeltaV Additions - blacklist IPCs
# blacklist:
# components:
# - Silicon
# - BorgChassis
# 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
id: Addicted
name: trait-addicted-name
description: trait-addicted-desc
category: Disabilities
components:
- type: Addicted
## 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: InPain
name: trait-inpain-name
description: trait-inpain-desc
traitGear: PillCanisterSoretizone
id: Blindness
name: trait-blindness-name
description: trait-blindness-desc
category: Disabilities
conditions:
- !type:HasCompCondition
component: Blindable
effects:
- !type:AddCompsEffect
components:
- type: Pain
- type: PermanentBlindness
- !type:SpawnItemInHandEffect
item: WhiteCane
- type: trait
id: Unborgable
name: trait-unborgable-name
description: trait-unborgable-desc
id: PoorVision
name: trait-poor-vision-name
description: trait-poor-vision-desc
category: Disabilities
conditions:
- !type:HasCompCondition
component: Blindable
effects:
- !type:AddCompsEffect
components:
- type: Unborgable # Automatically gets moved to the brain
- type: PermanentBlindness
blindness: 4
- !type:SpawnItemInHandEffect
item: ClothingEyesGlasses
- type: trait
id: Depression
name: trait-depression-name
description: trait-depression-desc
traitGear: PillCanisterNeurozenium
id: Monochromacy
name: trait-monochromacy-name
description: trait-monochromacy-desc
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
id: Dysgraphia
name: trait-dysgraphia-name
description: trait-dysgraphia-desc
category: Disabilities
effects:
- !type:AddCompsEffect
components:
- type: BlockWriting
- type: trait
id: Redshirt
name: trait-redshirt-name
description: trait-redshirt-desc
id: PainNumbness
name: trait-painnumbness-name
description: trait-painnumbness-desc
category: Disabilities
effects:
- !type:AddCompsEffect
components:
- type: Redshirt
overriddenComponents:
- type: MobThresholds
thresholds:
0: Alive
99.9: Critical
100: Dead
- type: PainNumbness
- type: trait
id: Hemophilia
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
id: Hushed
name: trait-hushed-name
description: trait-hushed-desc
category: SpeechTraits
category: Accents
cost: 0
blacklist:
components:
- BorgChassis
conditions:
- !type:HasCompCondition
component: BorgChassis
invert: true
effects:
- !type:AddCompsEffect
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