using System.Linq; using System.Text.RegularExpressions; using Content.Shared._Horizon.Bark; using Content.Shared._Horizon.Language; using Content.Shared._Horizon.FlavorText; using Content.Shared._NF.Bank; 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, -, ']"); // Horizon private static readonly Regex ICNameCaseRegex = new(@"^(?\w)|\b(?\w)(?=\w*$)"); public const int DefaultBalance = 30000; // Frontier /// /// 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 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 ProtoId 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; [DataField] // Frontier: Bank balance public int BankBalance { get; private set; } = DefaultBalance; // Frontier: Bank balance /// /// /// 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; // Horizon start public BarkData Bark = new(); public ErpStatus ErpStat = ErpStatus.No; public ProtoId Faction = "None"; [DataField] public string OOCFlavorText { get; set; } = string.Empty; [DataField] private HashSet> _languages = new(); public IReadOnlySet> Languages => _languages; // Horizon end /// /// /// 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, int age, Sex sex, Gender gender, int bankBalance, HumanoidCharacterAppearance appearance, SpawnPriorityPreference spawnPriority, Dictionary, JobPriority> jobPriorities, PreferenceUnavailableMode preferenceUnavailable, HashSet> antagPreferences, HashSet> traitPreferences, Dictionary loadouts, // Horizon start ErpStatus erp, ProtoId faction, string oocFlavor, BarkData bark, HashSet> languages) // Horizon end { Name = name; FlavorText = flavortext; Species = species; Age = age; Sex = sex; Gender = gender; BankBalance = bankBalance; Appearance = appearance; SpawnPriority = spawnPriority; _jobPriorities = jobPriorities; PreferenceUnavailable = preferenceUnavailable; _antagPreferences = antagPreferences; _traitPreferences = traitPreferences; _loadouts = loadouts; // Horizon start ErpStat = erp; Faction = faction; OOCFlavorText = oocFlavor; Bark = bark; _languages = languages; // Horizon end } /// Copy constructor but with overridable references (to prevent useless copies) private HumanoidCharacterProfile( HumanoidCharacterProfile other, Dictionary, JobPriority> jobPriorities, HashSet> antagPreferences, HashSet> traitPreferences, Dictionary loadouts) : this(other.Name, other.FlavorText, other.Species, other.Age, other.Sex, other.Gender, other.BankBalance, other.Appearance, other.SpawnPriority, jobPriorities, other.PreferenceUnavailable, antagPreferences, traitPreferences, loadouts, // Horizon start other.ErpStat, other.Faction, other.OOCFlavorText, other.Bark, other.Languages.ToHashSet()) // Horizon end { } /// Copy constructor public HumanoidCharacterProfile(HumanoidCharacterProfile other) : this(other.Name, other.FlavorText, other.Species, other.Age, other.Sex, other.Gender, other.BankBalance, other.Appearance.Clone(), other.SpawnPriority, new Dictionary, JobPriority>(other.JobPriorities), other.PreferenceUnavailable, new HashSet>(other.AntagPreferences), new HashSet>(other.TraitPreferences), new Dictionary(other.Loadouts), // Horizon start other.ErpStat, other.Faction, other.OOCFlavorText, other.Bark, other.Languages.ToHashSet()) // Horizon end { } /// /// 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 proto = IoCManager.Resolve(); // Horizon Languages return new() { Species = species, _languages = proto.Index(species).DefaultLanguages.ToHashSet() // Horizon Languages }; } // TODO: This should eventually not be a visual change only. public static HumanoidCharacterProfile Random(HashSet? ignoredSpecies = null, int balance = DefaultBalance) { 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: species, balance: balance); } public static HumanoidCharacterProfile RandomWithSpecies(string species = SharedHumanoidAppearanceSystem.DefaultSpecies, int balance = DefaultBalance) { var prototypeManager = IoCManager.Resolve(); var random = IoCManager.Resolve(); var sex = Sex.Unsexed; var age = 18; HashSet> languages = new(); // Horizon Languages 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 languages = speciesPrototype.DefaultLanguages.ToHashSet(); // Horizon Languages } 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), _languages = languages // Horizon Languages }; } 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 }; } // Frontier: this is probably an issue and should be removed. public HumanoidCharacterProfile WithBankBalance(int bankBalance) { return new(this) { BankBalance = bankBalance }; } // End Frontier 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, 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) { return new(this) { PreferenceUnavailable = mode }; } public HumanoidCharacterProfile WithAntagPreferences(IEnumerable> antagPreferences) { return new(this) { _antagPreferences = new (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, IPrototypeManager protoManager) { // null category is assumed to be default. if (!protoManager.TryIndex(traitId, out var traitProto)) return new(this); var category = traitProto.Category; // Category not found so dump it. TraitCategoryPrototype? traitCategory = null; if (category != null && !protoManager.TryIndex(category, out traitCategory)) return new(this); var list = new HashSet>(_traitPreferences) { traitId }; if (traitCategory == null || traitCategory.MaxTraitPoints < 0) { return new(this) { _traitPreferences = list, }; } var count = 0; foreach (var trait in list) { // If trait not found or another category don't count its points. if (!protoManager.TryIndex(trait, out var otherProto) || otherProto.Category != traitCategory) { continue; } count += otherProto.Cost; } if (count > traitCategory.MaxTraitPoints && traitProto.Cost != 0) { return new(this); } return new(this) { _traitPreferences = list, }; } public HumanoidCharacterProfile WithoutTraitPreference(ProtoId traitId, IPrototypeManager protoManager) { var list = new HashSet>(_traitPreferences); 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) ); // Frontier public string BankBalanceText => BankSystemExtensions.ToSpesoString(BankBalance); 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 (BankBalance != other.BankBalance) return false; // Frontier 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; // Horizon start if (!Bark.MemberwiseEquals(other.Bark)) return false; if (ErpStat != other.ErpStat) return false; if (Faction != other.Faction) return false; if (OOCFlavorText != other.OOCFlavorText) return false; if (!_languages.SequenceEqual(other._languages)) return false; // Horizon end 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; var maxNameLength = configManager.GetCVar(CCVars.MaxNameLength); 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 = Regex.Replace(name, @"[^A-Z,a-z,А-Я,а-я,0-9, -, ']", string.Empty); // Horizon /* * 0041-005A Basic Latin: Uppercase Latin Alphabet * 0061-007A Basic Latin: Lowercase Latin Alphabet * 00C0-00D6 Latin-1 Supplement: Letters I * 00D8-00F6 Latin-1 Supplement: Letters II * 00F8-00FF Latin-1 Supplement: Letters III * 0100-017F Latin Extended A: European Latin */ } 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; var maxFlavorTextLength = configManager.GetCVar(CCVars.MaxFlavorTextLength); if (FlavorText.Length > maxFlavorTextLength) { flavortext = FormattedMessage.RemoveMarkupOrThrow(FlavorText)[..maxFlavorTextLength]; } else { flavortext = FormattedMessage.RemoveMarkupOrThrow(FlavorText); } // Frontier //make sure theres no funny bank stuff going on var bankBalance = BankBalance; if (BankBalance <= 0) { bankBalance = 0; } // End Frontier 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) .ToList(); var traits = TraitPreferences .Where(prototypeManager.HasIndex) .ToList(); Name = name; FlavorText = flavortext; Age = age; Sex = sex; Gender = gender; BankBalance = bankBalance; 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(GetValidTraits(traits, prototypeManager)); // 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); } // Horizon start if (!prototypeManager.HasIndex(Faction)) Faction = "None"; if (_languages.Count <= 0) _languages = new(speciesPrototype.DefaultLanguages); List> langsInvalid = new(); foreach (var language in _languages) { if (!prototypeManager.Index(language).Roundstart && !speciesPrototype.UniqueLanguages.Contains(language)) langsInvalid.Add(language); } foreach (var lang in langsInvalid) { _languages.Remove(lang); } // Horizon end } /// /// Takes in an IEnumerable of traits and returns a List of the valid traits. /// public List> GetValidTraits(IEnumerable> traits, IPrototypeManager protoManager) { // Track points count for each group. var groups = new Dictionary(); var result = new List>(); foreach (var trait in traits) { if (!protoManager.TryIndex(trait, out var traitProto)) continue; // Always valid. if (traitProto.Category == null) { result.Add(trait); continue; } // No category so dump it. if (!protoManager.TryIndex(traitProto.Category, out var category)) continue; var existing = groups.GetOrNew(category.ID); existing += traitProto.Cost; // Too expensive. if (existing > category.MaxTraitPoints) continue; groups[category.ID] = existing; result.Add(trait); } return result; } 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(BankBalance); // Frontier 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, ICommonSession? session, ProtoId? species, IEntityManager entManager, IPrototypeManager protoManager) { if (!_loadouts.TryGetValue(id, out var loadout)) { loadout = new RoleLoadout(id); loadout.SetDefault(this, session, protoManager, force: true); } loadout.SetDefault(this, session, protoManager); return loadout; } public HumanoidCharacterProfile Clone() { return new HumanoidCharacterProfile(this); } #region Barks // _Horizon start public HumanoidCharacterProfile WithBarkProto(string bark) { return new(this) { Bark = Bark.WithProto(bark), }; } public HumanoidCharacterProfile WithBarkPitch(float pitch) { return new(this) { Bark = Bark.WithPitch(pitch), }; } public HumanoidCharacterProfile WithBarkMinVariation(float variation) { return new(this) { Bark = Bark.WithMinVar(variation), }; } public HumanoidCharacterProfile WithBarkMaxVariation(float variation) { return new(this) { Bark = Bark.WithMaxVar(variation), }; } public HumanoidCharacterProfile WithErpStatus(ErpStatus erp) { return new(this) { ErpStat = erp }; } public HumanoidCharacterProfile WithFaction(ProtoId faction) { return new(this) { Faction = faction }; } public HumanoidCharacterProfile WithOOCFlavorText(string flavorText) { return new(this) { OOCFlavorText = flavorText }; } #endregion #region Languages public HumanoidCharacterProfile WithLanguage(ProtoId language) { var proto = IoCManager.Resolve(); var species = proto.Index(Species); if (!proto.Index(language).Roundstart && !species.UniqueLanguages.Contains(language)) return new(this); if (_languages.Contains(language)) return new(this); if (_languages.Count >= species.MaxLanguages) return new(this); HashSet> list = new(_languages); list.Add(language); return new(this) { _languages = list, }; } public HumanoidCharacterProfile WithoutLanguage(ProtoId language) { var proto = IoCManager.Resolve(); var species = proto.Index(Species); if (!proto.Index(language).Roundstart && !species.UniqueLanguages.Contains(language)) return new(this); if (!_languages.Contains(language)) return new(this); if (_languages.Count <= 1) return new(this); HashSet> list = new(_languages); list.Remove(language); return new(this) { _languages = list, }; } #endregion // _Horizon end } }