using System.Linq; using System.Text.RegularExpressions; using Content.Shared.CCVar; using Content.Shared.GameTicking; using Content.Shared.Humanoid; using Content.Shared.Humanoid.Prototypes; using Content.Shared.Preferences.Loadouts; using Content.Shared.Roles; using Content.Shared.Traits; using Robust.Shared.Collections; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Serialization; using Robust.Shared.Utility; namespace Content.Shared.Preferences { /// /// Character profile. Looks immutable, but uses non-immutable semantics internally for serialization/code sanity purposes. /// [DataDefinition] [Serializable, NetSerializable] public sealed partial class HumanoidCharacterProfile : ICharacterProfile { private static readonly Regex RestrictedNameRegex = new("[^A-Z,a-z,0-9, -]"); private static readonly Regex ICNameCaseRegex = new(@"^(?\w)|\b(?\w)(?=\w*$)"); public const int MaxNameLength = 32; public const int MaxDescLength = 512; /// /// Job preferences for initial spawn. /// [DataField] private Dictionary _jobPriorities = new() { { SharedGameTicker.FallbackOverflowJob, JobPriority.High } }; /// /// Antags we have opted in to. /// [DataField] private HashSet _antagPreferences = new(); /// /// Enabled traits. /// [DataField] private HashSet _traitPreferences = new(); /// /// /// public IReadOnlyDictionary Loadouts => _loadouts; [DataField] private Dictionary _loadouts = new(); [DataField] public string Name { get; set; } = "John Doe"; /// /// Detailed text that can appear for the character if is enabled. /// [DataField] public string FlavorText { get; set; } = string.Empty; /// /// Associated for this profile. /// [DataField] public string Species { get; set; } = SharedHumanoidAppearanceSystem.DefaultSpecies; [DataField] public int Age { get; set; } = 18; [DataField] public Sex Sex { get; private set; } = Sex.Male; [DataField] public Gender Gender { get; private set; } = Gender.Male; /// /// /// public ICharacterAppearance CharacterAppearance => Appearance; /// /// Stores markings, eye colors, etc for the profile. /// [DataField] public HumanoidCharacterAppearance Appearance { get; set; } = new(); /// /// When spawning into a round what's the preferred spot to spawn. /// [DataField] public SpawnPriorityPreference SpawnPriority { get; private set; } = SpawnPriorityPreference.None; /// /// /// public IReadOnlyDictionary JobPriorities => _jobPriorities; /// /// /// public IReadOnlySet AntagPreferences => _antagPreferences; /// /// /// public IReadOnlySet TraitPreferences => _traitPreferences; /// /// If we're unable to get one of our preferred jobs do we spawn as a fallback job or do we stay in lobby. /// [DataField] public PreferenceUnavailableMode PreferenceUnavailable { get; private set; } = PreferenceUnavailableMode.SpawnAsOverflow; public HumanoidCharacterProfile( string name, string flavortext, string species, int age, Sex sex, Gender gender, HumanoidCharacterAppearance appearance, SpawnPriorityPreference spawnPriority, Dictionary jobPriorities, PreferenceUnavailableMode preferenceUnavailable, HashSet antagPreferences, HashSet traitPreferences, Dictionary loadouts) { Name = name; FlavorText = flavortext; Species = species; Age = age; Sex = sex; Gender = gender; Appearance = appearance; SpawnPriority = spawnPriority; _jobPriorities = jobPriorities; PreferenceUnavailable = preferenceUnavailable; _antagPreferences = antagPreferences; _traitPreferences = traitPreferences; _loadouts = loadouts; } /// Copy constructor public HumanoidCharacterProfile(HumanoidCharacterProfile other) : this(other.Name, other.FlavorText, other.Species, other.Age, other.Sex, other.Gender, other.Appearance.Clone(), other.SpawnPriority, new Dictionary(other.JobPriorities), other.PreferenceUnavailable, new HashSet(other.AntagPreferences), new HashSet(other.TraitPreferences), new Dictionary(other.Loadouts)) { } /// /// Get the default humanoid character profile, using internal constant values. /// Defaults to for the species. /// /// public HumanoidCharacterProfile() { } /// /// Return a default character profile, based on species. /// /// The species to use in this default profile. The default species is . /// Humanoid character profile with default settings. public static HumanoidCharacterProfile DefaultWithSpecies(string species = SharedHumanoidAppearanceSystem.DefaultSpecies) { return new() { Species = species, }; } // TODO: This should eventually not be a visual change only. public static HumanoidCharacterProfile Random(HashSet? ignoredSpecies = null) { var prototypeManager = IoCManager.Resolve(); var random = IoCManager.Resolve(); var species = random.Pick(prototypeManager .EnumeratePrototypes() .Where(x => ignoredSpecies == null ? x.RoundStart : x.RoundStart && !ignoredSpecies.Contains(x.ID)) .ToArray() ).ID; return RandomWithSpecies(species); } public static HumanoidCharacterProfile RandomWithSpecies(string species = SharedHumanoidAppearanceSystem.DefaultSpecies) { var prototypeManager = IoCManager.Resolve(); var random = IoCManager.Resolve(); var sex = Sex.Unsexed; var age = 18; if (prototypeManager.TryIndex(species, out var speciesPrototype)) { sex = random.Pick(speciesPrototype.Sexes); age = random.Next(speciesPrototype.MinAge, speciesPrototype.OldAge); // people don't look and keep making 119 year old characters with zero rp, cap it at middle aged } var gender = Gender.Epicene; switch (sex) { case Sex.Male: gender = Gender.Male; break; case Sex.Female: gender = Gender.Female; break; } var name = GetName(species, gender); return new HumanoidCharacterProfile() { Name = name, Sex = sex, Age = age, Gender = gender, Species = species, Appearance = HumanoidCharacterAppearance.Random(species, sex), }; } public HumanoidCharacterProfile WithName(string name) { return new(this) { Name = name }; } public HumanoidCharacterProfile WithFlavorText(string flavorText) { return new(this) { FlavorText = flavorText }; } public HumanoidCharacterProfile WithAge(int age) { return new(this) { Age = age }; } public HumanoidCharacterProfile WithSex(Sex sex) { return new(this) { Sex = sex }; } public HumanoidCharacterProfile WithGender(Gender gender) { return new(this) { Gender = gender }; } public HumanoidCharacterProfile WithSpecies(string species) { return new(this) { Species = species }; } public HumanoidCharacterProfile WithCharacterAppearance(HumanoidCharacterAppearance appearance) { return new(this) { Appearance = appearance }; } public HumanoidCharacterProfile WithSpawnPriorityPreference(SpawnPriorityPreference spawnPriority) { return new(this) { SpawnPriority = spawnPriority }; } public HumanoidCharacterProfile WithJobPriorities(IEnumerable> jobPriorities) { return new(this) { _jobPriorities = new Dictionary(jobPriorities), }; } public HumanoidCharacterProfile WithJobPriority(string jobId, JobPriority priority) { var dictionary = new Dictionary(_jobPriorities); if (priority == JobPriority.Never) { dictionary.Remove(jobId); } else { dictionary[jobId] = priority; } return new(this) { _jobPriorities = dictionary, }; } public HumanoidCharacterProfile WithPreferenceUnavailable(PreferenceUnavailableMode mode) { return new(this) { PreferenceUnavailable = mode }; } public HumanoidCharacterProfile WithAntagPreferences(IEnumerable antagPreferences) { return new(this) { _antagPreferences = new HashSet(antagPreferences), }; } public HumanoidCharacterProfile WithAntagPreference(string antagId, bool pref) { var list = new HashSet(_antagPreferences); if (pref) { list.Add(antagId); } else { list.Remove(antagId); } return new(this) { _antagPreferences = list, }; } public HumanoidCharacterProfile WithTraitPreference(string traitId, string? categoryId, bool pref) { var prototypeManager = IoCManager.Resolve(); var traitProto = prototypeManager.Index(traitId); TraitCategoryPrototype? categoryProto = null; if (categoryId != null && categoryId != "default") categoryProto = prototypeManager.Index(categoryId); var list = new HashSet(_traitPreferences); if (pref) { list.Add(traitId); if (categoryProto == null || categoryProto.MaxTraitPoints < 0) { return new(this) { _traitPreferences = list, }; } var count = 0; foreach (var trait in list) { var traitProtoTemp = prototypeManager.Index(trait); count += traitProtoTemp.Cost; } if (count > categoryProto.MaxTraitPoints && traitProto.Cost != 0) { return new(this) { _traitPreferences = _traitPreferences, }; } } else { list.Remove(traitId); } return new(this) { _traitPreferences = list, }; } public string Summary => Loc.GetString( "humanoid-character-profile-summary", ("name", Name), ("gender", Gender.ToString().ToLowerInvariant()), ("age", Age) ); public bool MemberwiseEquals(ICharacterProfile maybeOther) { if (maybeOther is not HumanoidCharacterProfile other) return false; if (Name != other.Name) return false; if (Age != other.Age) return false; if (Sex != other.Sex) return false; if (Gender != other.Gender) return false; if (Species != other.Species) return false; if (PreferenceUnavailable != other.PreferenceUnavailable) return false; if (SpawnPriority != other.SpawnPriority) return false; if (!_jobPriorities.SequenceEqual(other._jobPriorities)) return false; if (!_antagPreferences.SequenceEqual(other._antagPreferences)) return false; if (!_traitPreferences.SequenceEqual(other._traitPreferences)) return false; if (!Loadouts.SequenceEqual(other.Loadouts)) return false; if (FlavorText != other.FlavorText) return false; return Appearance.MemberwiseEquals(other.Appearance); } public void EnsureValid(ICommonSession session, IDependencyCollection collection) { var configManager = collection.Resolve(); var prototypeManager = collection.Resolve(); if (!prototypeManager.TryIndex(Species, out var speciesPrototype) || speciesPrototype.RoundStart == false) { Species = SharedHumanoidAppearanceSystem.DefaultSpecies; speciesPrototype = prototypeManager.Index(Species); } var sex = Sex switch { Sex.Male => Sex.Male, Sex.Female => Sex.Female, Sex.Unsexed => Sex.Unsexed, _ => Sex.Male // Invalid enum values. }; // ensure the species can be that sex and their age fits the founds if (!speciesPrototype.Sexes.Contains(sex)) sex = speciesPrototype.Sexes[0]; var age = Math.Clamp(Age, speciesPrototype.MinAge, speciesPrototype.MaxAge); var gender = Gender switch { Gender.Epicene => Gender.Epicene, Gender.Female => Gender.Female, Gender.Male => Gender.Male, Gender.Neuter => Gender.Neuter, _ => Gender.Epicene // Invalid enum values. }; string name; if (string.IsNullOrEmpty(Name)) { name = GetName(Species, gender); } else if (Name.Length > MaxNameLength) { name = Name[..MaxNameLength]; } else { name = Name; } name = name.Trim(); if (configManager.GetCVar(CCVars.RestrictedNames)) { name = RestrictedNameRegex.Replace(name, string.Empty); } if (configManager.GetCVar(CCVars.ICNameCase)) { // This regex replaces the first character of the first and last words of the name with their uppercase version name = ICNameCaseRegex.Replace(name, m => m.Groups["word"].Value.ToUpper()); } if (string.IsNullOrEmpty(name)) { name = GetName(Species, gender); } string flavortext; if (FlavorText.Length > MaxDescLength) { flavortext = FormattedMessage.RemoveMarkup(FlavorText)[..MaxDescLength]; } else { flavortext = FormattedMessage.RemoveMarkup(FlavorText); } var appearance = HumanoidCharacterAppearance.EnsureValid(Appearance, Species, Sex); var prefsUnavailableMode = PreferenceUnavailable switch { PreferenceUnavailableMode.StayInLobby => PreferenceUnavailableMode.StayInLobby, PreferenceUnavailableMode.SpawnAsOverflow => PreferenceUnavailableMode.SpawnAsOverflow, _ => PreferenceUnavailableMode.StayInLobby // Invalid enum values. }; var spawnPriority = SpawnPriority switch { SpawnPriorityPreference.None => SpawnPriorityPreference.None, SpawnPriorityPreference.Arrivals => SpawnPriorityPreference.Arrivals, SpawnPriorityPreference.Cryosleep => SpawnPriorityPreference.Cryosleep, _ => SpawnPriorityPreference.None // Invalid enum values. }; var priorities = new Dictionary(JobPriorities .Where(p => prototypeManager.TryIndex(p.Key, out var job) && job.SetPreference && p.Value switch { JobPriority.Never => false, // Drop never since that's assumed default. JobPriority.Low => true, JobPriority.Medium => true, JobPriority.High => true, _ => false })); var antags = AntagPreferences .Where(id => prototypeManager.TryIndex(id, out var antag) && antag.SetPreference) .ToList(); var traits = TraitPreferences .Where(prototypeManager.HasIndex) .ToList(); Name = name; FlavorText = flavortext; Age = age; Sex = sex; Gender = gender; Appearance = appearance; SpawnPriority = spawnPriority; _jobPriorities.Clear(); foreach (var (job, priority) in priorities) { _jobPriorities.Add(job, priority); } PreferenceUnavailable = prefsUnavailableMode; _antagPreferences.Clear(); _antagPreferences.UnionWith(antags); _traitPreferences.Clear(); _traitPreferences.UnionWith(traits); // Checks prototypes exist for all loadouts and dump / set to default if not. var toRemove = new ValueList(); foreach (var (roleName, loadouts) in _loadouts) { if (!prototypeManager.HasIndex(roleName)) { toRemove.Add(roleName); continue; } loadouts.EnsureValid(this, session, collection); } foreach (var value in toRemove) { _loadouts.Remove(value); } } public ICharacterProfile Validated(ICommonSession session, IDependencyCollection collection) { var profile = new HumanoidCharacterProfile(this); profile.EnsureValid(session, collection); return profile; } // sorry this is kind of weird and duplicated, /// working inside these non entity systems is a bit wack public static string GetName(string species, Gender gender) { var namingSystem = IoCManager.Resolve().GetEntitySystem(); return namingSystem.GetName(species, gender); } public override bool Equals(object? obj) { return ReferenceEquals(this, obj) || obj is HumanoidCharacterProfile other && Equals(other); } public override int GetHashCode() { var hashCode = new HashCode(); hashCode.Add(_jobPriorities); hashCode.Add(_antagPreferences); hashCode.Add(_traitPreferences); hashCode.Add(_loadouts); hashCode.Add(Name); hashCode.Add(FlavorText); hashCode.Add(Species); hashCode.Add(Age); hashCode.Add((int)Sex); hashCode.Add((int)Gender); hashCode.Add(Appearance); hashCode.Add((int)SpawnPriority); hashCode.Add((int)PreferenceUnavailable); return hashCode.ToHashCode(); } public void SetLoadout(RoleLoadout loadout) { _loadouts[loadout.Role.Id] = loadout; } public HumanoidCharacterProfile WithLoadout(RoleLoadout loadout) { // Deep copies so we don't modify the DB profile. var copied = new Dictionary(); foreach (var proto in _loadouts) { if (proto.Key == loadout.Role) continue; copied[proto.Key] = proto.Value.Clone(); } copied[loadout.Role] = loadout.Clone(); var profile = Clone(); profile._loadouts = copied; return profile; } public RoleLoadout GetLoadoutOrDefault(string id, ProtoId? species, IEntityManager entManager, IPrototypeManager protoManager) { if (!_loadouts.TryGetValue(id, out var loadout)) { loadout = new RoleLoadout(id); loadout.SetDefault(protoManager, force: true); } loadout.SetDefault(protoManager); return loadout; } public HumanoidCharacterProfile Clone() { return new HumanoidCharacterProfile(this); } } }