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.Humanoid.Prototypes; using Content.Shared.Preferences; using Content.Shared.Roles; using Content.Shared.StatusEffectNew; using Robust.Shared.Configuration; using Robust.Shared.Player; using Robust.Shared.Prototypes; namespace Content.Server._DV.Traits; /// /// Server system that validates and applies traits to players on spawn. /// 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!; [Dependency] private readonly StatusEffectsSystem _statusEffects = default!; private int _maxTraitCount; private int _maxTraitPoints; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(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(args.JobId, out var jobProto) || !jobProto.ApplyTraits) return; // Use the species ID from the profile if for some reason we can't get the humanoid appearance ProtoId? speciesId = args.Profile.Species; if (TryComp(args.Mob, out var humanoid)) speciesId = humanoid.Species; // Track disabled traits and reasons var disabledTraits = new Dictionary, List>(); // Validate and collect valid traits var validTraits = ValidateTraits(args.Mob, args.Profile.TraitPreferences, args.Player, args.JobId, speciesId, args.Profile, disabledTraits); // Apply valid traits foreach (var traitId in validTraits) { if (!_prototype.TryIndex(traitId, out var trait)) continue; ApplyTrait(args.Mob, trait); } // Send disabled traits notification to client if any were rejected if (disabledTraits.Count > 0) { RaiseNetworkEvent(new DisabledTraitsEvent(disabledTraits), args.Player); } } /// /// Validates a set of trait selections against all rules and returns the valid subset. /// private HashSet> ValidateTraits( EntityUid player, IReadOnlySet> selectedTraits, ICommonSession? session, string? jobId, string? speciesId, HumanoidCharacterProfile? profile, Dictionary, List> disabledTraits) { var validTraits = new HashSet>(); var totalPoints = 0; var traitCount = 0; var categoryTraitCounts = new Dictionary, int>(); var categoryPointTotals = new Dictionary, int>(); // Build condition context var conditionCtx = new TraitConditionContext { Player = player, Session = session, EntMan = EntityManager, Proto = _prototype, CompFactory = _factory, LogMan = _log, JobId = jobId, SpeciesId = speciesId, Profile = profile, StatusEffects = _statusEffects }; foreach (var traitId in selectedTraits) { if (!_prototype.TryIndex(traitId, out var trait)) { Log.Warning($"Unknown trait ID in player preferences: {traitId}"); continue; } var rejectionReasons = new List(); // Check global trait count limit if (traitCount >= _maxTraitCount) { Log.Warning($"Trait {traitId} rejected: global trait count limit ({_maxTraitCount}) exceeded"); rejectionReasons.Add(Loc.GetString("disabled-traits-reason-global-limit")); disabledTraits[traitId] = rejectionReasons; continue; } // Check global points limit if (totalPoints + trait.Cost > _maxTraitPoints) { Log.Warning($"Trait {traitId} rejected: global points limit ({_maxTraitPoints}) would be exceeded"); rejectionReasons.Add(Loc.GetString("disabled-traits-reason-points-limit")); disabledTraits[traitId] = rejectionReasons; continue; } // Check category limits if (!ValidateCategoryLimits(trait, categoryTraitCounts, categoryPointTotals, rejectionReasons)) { Log.Warning($"Trait {traitId} rejected: category limits exceeded"); disabledTraits[traitId] = rejectionReasons; 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}"); if (_prototype.TryIndex(validTraitId, out var conflictTrait)) { rejectionReasons.Add(Loc.GetString("disabled-traits-reason-conflict", ("trait", Loc.GetString(conflictTrait.Name)))); } 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"); rejectionReasons.Add(Loc.GetString("disabled-traits-reason-conflict", ("trait", Loc.GetString(validTrait.Name)))); hasConflict = true; break; } } if (hasConflict) { disabledTraits[traitId] = rejectionReasons; continue; } // Check all conditions if (!CheckConditions(trait, conditionCtx, rejectionReasons)) { Log.Warning($"Trait {traitId} rejected: conditions not met"); disabledTraits[traitId] = rejectionReasons; 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; } /// /// Validates that adding a trait wouldn't exceed category-specific limits. /// private bool ValidateCategoryLimits( TraitPrototype trait, Dictionary, int> categoryTraitCounts, Dictionary, int> categoryPointTotals, List rejectionReasons) { 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) { rejectionReasons.Add(Loc.GetString("disabled-traits-reason-category-limit", ("category", Loc.GetString(category.Name)))); return false; } // Check category points limit if (category.MaxPoints.HasValue && currentPoints + trait.Cost > category.MaxPoints.Value) { rejectionReasons.Add(Loc.GetString("disabled-traits-reason-category-points", ("category", Loc.GetString(category.Name)))); return false; } return true; } /// /// Checks all conditions on a trait. /// private bool CheckConditions(TraitPrototype trait, TraitConditionContext ctx, List rejectionReasons) { foreach (var condition in trait.Conditions) { if (condition.Evaluate(ctx)) continue; // Get human-readable reason from the condition var tooltip = condition.GetTooltip(ctx.Proto, Loc); if (!string.IsNullOrEmpty(tooltip)) rejectionReasons.Add(tooltip); return false; } return true; } /// /// Applies a trait's effects to an entity. /// 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, StatusEffects = _statusEffects, }; 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}"); } } } /// /// Handles the SpawnItemInHandEffect since it requires server-side systems. /// private void ApplySpawnItemEffect(EntityUid player, SpawnItemInHandEffect effect, TransformComponent transform) { if (!TryComp(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"); } }