using System.Linq; using System.Text.RegularExpressions; using Content.Shared._White.TTS; using Content.Shared.CCVar; using Content.Shared.Clothing.Loadouts.Prototypes; using Content.Shared.Clothing.Loadouts.Systems; using Content.Shared.GameTicking; using Content.Shared.Humanoid; using Content.Shared.Humanoid.Prototypes; using Content.Shared.Roles; using Content.Shared.Traits; 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-Za-z0-9А-Яа-я '\-]"); private static readonly Regex ICNameCaseRegex = new(@"^(?\w)|\b(?\w)(?=\w*$)"); public const int MaxNameLength = 64; public const int MaxDescLength = 1024; /// Job preferences for initial spawn [DataField] private Dictionary, JobPriority> _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 HashSet LoadoutPreferences => _loadoutPreferences; [DataField] private HashSet _loadoutPreferences = 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 ProtoId Species { get; set; } = SharedHumanoidAppearanceSystem.DefaultSpecies; // EE -- Contractors Change Start [DataField] public string Nationality { get; set; } = SharedHumanoidAppearanceSystem.DefaultNationality; [DataField] public string Employer { get; set; } = SharedHumanoidAppearanceSystem.DefaultEmployer; [DataField] public string Lifepath { get; set; } = SharedHumanoidAppearanceSystem.DefaultLifepath; // EE -- Contractors Change End [DataField] public string Customspeciename { get; set; } = ""; [DataField] public float Height { get; private set; } [DataField] public float Width { get; private set; } [DataField] public int Age { get; set; } = 18; [DataField] public Sex Sex { get; private set; } = Sex.Male; // WD EDIT START [DataField] public string BodyType { get; set; } = SharedHumanoidAppearanceSystem.DefaultBodyType; [DataField] public string Voice { get; set; } = SharedHumanoidAppearanceSystem.DefaultVoice; // WD EDIT END [DataField] public Gender Gender { get; private set; } = Gender.Male; [DataField] public string? DisplayPronouns { get; set; } [DataField] public string? StationAiName { get; set; } [DataField] public string? CyborgName { get; set; } // WD EDIT START [DataField] public string? ClownName { get; set; } // WD EDIT END /// 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, JobPriority> 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, string customspeciename, // EE -- Contractors Change Start string nationality, string employer, string lifepath, // EE -- Contractors Change End float height, float width, int age, Sex sex, string voice, // WD EDIT string bodyType, // WD EDIT Gender gender, string? displayPronouns, string? stationAiName, string? cyborgName, string? clownName, // WD EDIT HumanoidCharacterAppearance appearance, SpawnPriorityPreference spawnPriority, Dictionary, JobPriority> jobPriorities, PreferenceUnavailableMode preferenceUnavailable, HashSet> antagPreferences, HashSet> traitPreferences, HashSet loadoutPreferences) { Name = name; FlavorText = flavortext; Species = species; Customspeciename = customspeciename; // EE -- Contractors Change Start Nationality = nationality; Employer = employer; Lifepath = lifepath; // EE -- Contractors Change End Height = height; Width = width; Age = age; Sex = sex; Voice = voice; // WD EDIT BodyType = bodyType; // WD EDIT Gender = gender; DisplayPronouns = displayPronouns; StationAiName = stationAiName; CyborgName = cyborgName; ClownName = clownName; // WD EDIT Appearance = appearance; SpawnPriority = spawnPriority; _jobPriorities = jobPriorities; PreferenceUnavailable = preferenceUnavailable; _antagPreferences = antagPreferences; _traitPreferences = traitPreferences; _loadoutPreferences = loadoutPreferences; var hasHighPrority = false; foreach (var (key, value) in _jobPriorities) { if (value == JobPriority.Never) _jobPriorities.Remove(key); else if (value != JobPriority.High) continue; if (hasHighPrority) _jobPriorities[key] = JobPriority.Medium; hasHighPrority = true; } } /// Copy constructor public HumanoidCharacterProfile(HumanoidCharacterProfile other) : this( other.Name, other.FlavorText, other.Species, other.Customspeciename, // EE -- Contractors Change Start other.Nationality, other.Employer, other.Lifepath, // EE -- Contractors Change End other.Height, other.Width, other.Age, other.Sex, other.Voice, // WD EDIT other.BodyType, // WD EDIT other.Gender, other.DisplayPronouns, other.StationAiName, other.CyborgName, other.ClownName, // WD EDIT other.Appearance.Clone(), other.SpawnPriority, new Dictionary, JobPriority>(other.JobPriorities), other.PreferenceUnavailable, new HashSet>(other.AntagPreferences), new HashSet>(other.TraitPreferences), new HashSet(other.LoadoutPreferences)) { } /// /// 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) { var prototypeManager = IoCManager.Resolve(); var skinColor = SkinColor.ValidHumanSkinTone; if (prototypeManager.TryIndex(species, out var speciesPrototype)) skinColor = speciesPrototype.DefaultSkinTone; return new() { Species = species, Appearance = new() { SkinColor = skinColor, }, Nationality = SharedHumanoidAppearanceSystem.DefaultNationality, Employer = SharedHumanoidAppearanceSystem.DefaultEmployer, Lifepath = SharedHumanoidAppearanceSystem.DefaultLifepath, }; } // 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(); // WWDP edit start var specieslist = prototypeManager .EnumeratePrototypes() .Where(x => !ignoredSpecies?.Contains(x.ID) ?? true) // WWDP .ToArray(); if (specieslist.Length == 0) // Fallback specieslist = [prototypeManager.Index(SharedHumanoidAppearanceSystem.DefaultSpecies)]; var species = random.Pick(specieslist).ID; // WWDP edit end 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; var bodyType = SharedHumanoidAppearanceSystem.DefaultBodyType; // WD EDIT 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 bodyType = speciesPrototype.BodyTypes.First(); // WD EDIT } var gender = Gender.Epicene; switch (sex) { case Sex.Male: gender = Gender.Male; break; case Sex.Female: gender = Gender.Female; break; } // WD EDIT START var voiceId = random.Pick(prototypeManager .EnumeratePrototypes() .Where(o => CanHaveVoice(o, sex)).ToArray() ).ID; // WD EDIT END var name = GetName(species, gender); return new HumanoidCharacterProfile() { Name = name, Sex = sex, Age = age, Gender = gender, Voice = voiceId, // WD EDIT BodyType = bodyType, // WD EDIT Species = species, Appearance = HumanoidCharacterAppearance.Random(species, sex), Nationality = SharedHumanoidAppearanceSystem.DefaultNationality, Employer = SharedHumanoidAppearanceSystem.DefaultEmployer, Lifepath = SharedHumanoidAppearanceSystem.DefaultLifepath, }; } public HumanoidCharacterProfile WithName(string name) => new(this) { Name = name }; public HumanoidCharacterProfile WithFlavorText(string flavorText) => new(this) { FlavorText = flavorText }; public HumanoidCharacterProfile WithVoice(string voice) => new(this) { Voice = voice }; // WD EDIT public HumanoidCharacterProfile WithBodyType(string bodyType) => new(this) { BodyType = bodyType }; // WD EDIT public HumanoidCharacterProfile WithAge(int age) => new(this) { Age = age }; // EE - Contractors Change Start public HumanoidCharacterProfile WithNationality(string nationality) => new(this) { Nationality = nationality }; public HumanoidCharacterProfile WithEmployer(string employer) => new(this) { Employer = employer }; public HumanoidCharacterProfile WithLifepath(string lifepath) => new(this) { Lifepath = lifepath }; // EE - Contractors Change End public HumanoidCharacterProfile WithSex(Sex sex) => new(this) { Sex = sex }; public HumanoidCharacterProfile WithGender(Gender gender) => new(this) { Gender = gender }; public HumanoidCharacterProfile WithDisplayPronouns(string? displayPronouns) => new(this) { DisplayPronouns = displayPronouns }; public HumanoidCharacterProfile WithStationAiName(string? stationAiName) => new(this) { StationAiName = stationAiName }; public HumanoidCharacterProfile WithCyborgName(string? cyborgName) => new(this) { CyborgName = cyborgName }; public HumanoidCharacterProfile WithClownName(string? clownName) => new(this) { ClownName = clownName }; // WD EDIT public HumanoidCharacterProfile WithSpecies(string species) => new(this) { Species = species }; public HumanoidCharacterProfile WithCustomSpeciesName(string customspeciename) => new(this) { Customspeciename = customspeciename }; public HumanoidCharacterProfile WithHeight(float height) => new(this) { Height = height }; public HumanoidCharacterProfile WithWidth(float width) => new(this) { Width = width }; public HumanoidCharacterProfile WithCharacterAppearance(HumanoidCharacterAppearance appearance) => new(this) { Appearance = appearance }; public HumanoidCharacterProfile WithSpawnPriorityPreference(SpawnPriorityPreference spawnPriority) => new(this) { SpawnPriority = spawnPriority }; public HumanoidCharacterProfile WithJobPriorities(IEnumerable, JobPriority>> jobPriorities) { var dictionary = new Dictionary, JobPriority>(jobPriorities); var hasHighPrority = false; foreach (var (key, value) in dictionary) { if (value == JobPriority.Never) dictionary.Remove(key); else if (value != JobPriority.High) continue; if (hasHighPrority) dictionary[key] = JobPriority.Medium; hasHighPrority = true; } return new(this) { _jobPriorities = dictionary }; } public HumanoidCharacterProfile WithJobPriority(ProtoId jobId, JobPriority priority) { var dictionary = new Dictionary, JobPriority>(_jobPriorities); if (priority == JobPriority.Never) dictionary.Remove(jobId); else if (priority == JobPriority.High) { // There can only ever be one high priority job. foreach (var (job, value) in dictionary) { if (value == JobPriority.High) dictionary[job] = JobPriority.Medium; } dictionary[jobId] = priority; } else dictionary[jobId] = priority; return new(this) { _jobPriorities = dictionary }; } public HumanoidCharacterProfile WithPreferenceUnavailable(PreferenceUnavailableMode mode) => new(this) { PreferenceUnavailable = mode }; public HumanoidCharacterProfile WithAntagPreferences(IEnumerable> antagPreferences) => new(this) { _antagPreferences = new HashSet>(antagPreferences) }; public HumanoidCharacterProfile WithAntagPreference(ProtoId 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(ProtoId traitId, bool pref) { var list = new HashSet>(_traitPreferences); if (pref) list.Add(traitId); else list.Remove(traitId); return new(this) { _traitPreferences = list }; } public HumanoidCharacterProfile WithLoadoutPreference( string loadoutId, bool pref, string? customName = null, string? customDescription = null, string? customColor = null, bool? customHeirloom = null) { var list = new HashSet(_loadoutPreferences); list.RemoveWhere(l => l.LoadoutName == loadoutId); if (pref) list.Add(new(loadoutId, customName, customDescription, customColor, customHeirloom) { Selected = pref }); return new HumanoidCharacterProfile(this) { _loadoutPreferences = list }; } public string Summary => Loc.GetString( "humanoid-character-profile-summary", ("name", Name), ("gender", Gender.ToString().ToLowerInvariant()), ("age", Age) ); public bool MemberwiseEquals(ICharacterProfile maybeOther) { return maybeOther is HumanoidCharacterProfile other && Name == other.Name && Age == other.Age && Sex == other.Sex && Voice == other.Voice // WD EDIT && BodyType == other.BodyType // WD EDIT && Gender == other.Gender && Species == other.Species // EE - Contractors Change Start && Nationality == other.Nationality && Employer == other.Employer && Lifepath == other.Lifepath // EE - Contractors Change End && PreferenceUnavailable == other.PreferenceUnavailable && SpawnPriority == other.SpawnPriority && _jobPriorities.SequenceEqual(other._jobPriorities) && _antagPreferences.SequenceEqual(other._antagPreferences) && _traitPreferences.SequenceEqual(other._traitPreferences) && LoadoutPreferences.SequenceEqual(other.LoadoutPreferences) && Appearance.MemberwiseEquals(other.Appearance) && FlavorText == other.FlavorText; } 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. }; var bodyType = speciesPrototype.BodyTypes.Contains(BodyType) ? BodyType : speciesPrototype.BodyTypes.First(); // WD EDIT 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()); } var customspeciename = !speciesPrototype.CustomName || string.IsNullOrEmpty(Customspeciename) ? "" : Customspeciename.Length > MaxNameLength ? FormattedMessage.RemoveMarkupPermissive(Customspeciename)[..MaxNameLength] : FormattedMessage.RemoveMarkupPermissive(Customspeciename); if (string.IsNullOrEmpty(name)) { name = GetName(Species, gender); } string flavortext; if (FlavorText.Length > MaxDescLength) { flavortext = FormattedMessage.RemoveMarkupPermissive(FlavorText)[..MaxDescLength]; } else { flavortext = FormattedMessage.RemoveMarkupPermissive(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, JobPriority>(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 hasHighPrio = false; foreach (var (key, value) in priorities) { if (value != JobPriority.High) continue; if (hasHighPrio) priorities[key] = JobPriority.Medium; hasHighPrio = true; } var antags = AntagPreferences .Where(id => prototypeManager.TryIndex(id, out var antag) && antag.SetPreference) .Distinct() .ToList(); var traits = TraitPreferences .Where(prototypeManager.HasIndex) .Distinct() .ToList(); var loadouts = LoadoutPreferences .Where(l => prototypeManager.HasIndex(l.LoadoutName)) .Distinct() .ToList(); Name = name; Customspeciename = customspeciename; FlavorText = flavortext; Age = age; Sex = sex; Gender = gender; BodyType = bodyType; // WD EDIT 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); _loadoutPreferences.Clear(); _loadoutPreferences.UnionWith(loadouts); // WD EDIT START prototypeManager.TryIndex(Voice, out var voice); if (voice is null || !CanHaveVoice(voice, Sex)) Voice = SharedHumanoidAppearanceSystem.DefaultSexVoice[sex]; // WD EDIT END } // WD EDIT START public static bool CanHaveVoice(TTSVoicePrototype voice, Sex sex) { return voice.RoundStart && sex == Sex.Unsexed || voice.Sex == sex || voice.Sex == Sex.Unsexed; } // WD EDIT END 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 && MemberwiseEquals(other); } public override int GetHashCode() { var hashCode = new HashCode(); hashCode.Add(_jobPriorities); hashCode.Add(_antagPreferences); hashCode.Add(_traitPreferences); hashCode.Add(_loadoutPreferences); hashCode.Add(Name); hashCode.Add(FlavorText); hashCode.Add(Species); hashCode.Add(Employer); hashCode.Add(Nationality); hashCode.Add(Lifepath); hashCode.Add(Age); hashCode.Add((int) Sex); hashCode.Add((int) Gender); hashCode.Add(Voice); // WD EDIT hashCode.Add(BodyType); // WD EDIT hashCode.Add(Appearance); hashCode.Add((int) SpawnPriority); hashCode.Add((int) PreferenceUnavailable); hashCode.Add(Customspeciename); return hashCode.ToHashCode(); } public HumanoidCharacterProfile Clone() { return new HumanoidCharacterProfile(this); } }