using System.Diagnostics.CodeAnalysis; using System.Linq; using Content.Server.Administration.Logs; using Content.Server.GameTicking; using Content.Server.Ghost; using Content.Server.Ghost.Components; using Content.Server.Mind.Components; using Content.Server.Objectives; using Content.Server.Players; using Content.Server.Roles; using Content.Shared.Database; using Content.Shared.Examine; using Content.Shared.Mobs.Systems; using Content.Shared.Interaction.Events; using Content.Shared.Mobs.Components; using Robust.Server.GameObjects; using Robust.Server.Player; using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Server.Mind; public sealed class MindSystem : EntitySystem { [Dependency] private readonly IMapManager _mapManager = default!; [Dependency] private readonly GameTicker _gameTicker = default!; [Dependency] private readonly MobStateSystem _mobStateSystem = default!; [Dependency] private readonly GhostSystem _ghostSystem = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly ActorSystem _actor = default!; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnShutdown); SubscribeLocalEvent(OnExamined); SubscribeLocalEvent(OnSuicide); SubscribeLocalEvent(OnTerminating); SubscribeLocalEvent(OnDetached); } private void OnDetached(EntityUid uid, VisitingMindComponent component, PlayerDetachedEvent args) { component.Mind = null; RemCompDeferred(uid, component); } private void OnTerminating(EntityUid uid, VisitingMindComponent component, ref EntityTerminatingEvent args) { if (component.Mind?.Session?.AttachedEntity == uid) UnVisit(component.Mind); } public void SetGhostOnShutdown(EntityUid uid, bool value, MindContainerComponent? mind = null) { if (!Resolve(uid, ref mind)) return; mind.GhostOnShutdown = value; } /// /// Don't call this unless you know what the hell you're doing. /// Use instead. /// If that doesn't cover it, make something to cover it. /// private void InternalAssignMind(EntityUid uid, Mind value, MindContainerComponent? mind = null) { if (!Resolve(uid, ref mind)) return; mind.Mind = value; RaiseLocalEvent(uid, new MindAddedMessage(), true); } /// /// Don't call this unless you know what the hell you're doing. /// Use instead. /// If that doesn't cover it, make something to cover it. /// private void InternalEjectMind(EntityUid uid, MindContainerComponent? mind = null) { if (!Resolve(uid, ref mind, false)) return; RaiseLocalEvent(uid, new MindRemovedMessage(), true); mind.Mind = null; } private void OnShutdown(EntityUid uid, MindContainerComponent mindContainerComp, ComponentShutdown args) { // Let's not create ghosts if not in the middle of the round. if (_gameTicker.RunLevel != GameRunLevel.InRound) return; if (!TryGetMind(uid, out var mind, mindContainerComp)) return; if (mind.VisitingEntity is {Valid: true} visiting) { if (TryComp(visiting, out GhostComponent? ghost)) { _ghostSystem.SetCanReturnToBody(ghost, false); } TransferTo(mind, visiting); } else if (mindContainerComp.GhostOnShutdown) { // Changing an entities parents while deleting is VERY sus. This WILL throw exceptions. // TODO: just find the applicable spawn position directly without actually updating the transform's parent. Transform(uid).AttachToGridOrMap(); var spawnPosition = Transform(uid).Coordinates; // Use a regular timer here because the entity has probably been deleted. Timer.Spawn(0, () => { // Make extra sure the round didn't end between spawning the timer and it being executed. if (_gameTicker.RunLevel != GameRunLevel.InRound) return; // Async this so that we don't throw if the grid we're on is being deleted. var gridId = spawnPosition.GetGridUid(EntityManager); if (!spawnPosition.IsValid(EntityManager) || gridId == EntityUid.Invalid || !_mapManager.GridExists(gridId)) { spawnPosition = _gameTicker.GetObserverSpawnPoint(); } // TODO refactor observer spawning. // please. if (!spawnPosition.IsValid(EntityManager)) { // This should be an error, if it didn't cause tests to start erroring when they delete a player. Log.Warning($"Entity \"{ToPrettyString(uid)}\" for {mind.CharacterName} was deleted, and no applicable spawn location is available."); TransferTo(mind, null); return; } var ghost = Spawn("MobObserver", spawnPosition); var ghostComponent = Comp(ghost); _ghostSystem.SetCanReturnToBody(ghostComponent, false); // Log these to make sure they're not causing the GameTicker round restart bugs... Log.Debug($"Entity \"{ToPrettyString(uid)}\" for {mind.CharacterName} was deleted, spawned \"{ToPrettyString(ghost)}\"."); var val = mind.CharacterName ?? string.Empty; MetaData(ghost).EntityName = val; TransferTo(mind, ghost); }); } } private void OnExamined(EntityUid uid, MindContainerComponent mindContainer, ExaminedEvent args) { if (!mindContainer.ShowExamineInfo || !args.IsInDetailsRange) return; var dead = _mobStateSystem.IsDead(uid); var hasSession = mindContainer.Mind?.Session; if (dead && hasSession == null) args.PushMarkup($"[color=yellow]{Loc.GetString("comp-mind-examined-dead-and-ssd", ("ent", uid))}[/color]"); else if (dead) args.PushMarkup($"[color=red]{Loc.GetString("comp-mind-examined-dead", ("ent", uid))}[/color]"); else if (!mindContainer.HasMind) args.PushMarkup($"[color=mediumpurple]{Loc.GetString("comp-mind-examined-catatonic", ("ent", uid))}[/color]"); else if (hasSession == null) args.PushMarkup($"[color=yellow]{Loc.GetString("comp-mind-examined-ssd", ("ent", uid))}[/color]"); } private void OnSuicide(EntityUid uid, MindContainerComponent component, SuicideEvent args) { if (args.Handled) return; if (component.HasMind && component.Mind.PreventSuicide) { args.BlockSuicideAttempt(true); } } public Mind? GetMind(EntityUid uid, MindContainerComponent? mind = null) { if (!Resolve(uid, ref mind)) return null; if (mind.HasMind) return mind.Mind; return null; } public Mind CreateMind(NetUserId? userId, string? name = null) { var mind = new Mind(userId); mind.CharacterName = name; ChangeOwningPlayer(mind, userId); return mind; } /// /// True if the OwnedEntity of this mind is physically dead. /// This specific definition, as opposed to CharacterDeadIC, is used to determine if ghosting should allow return. /// public bool IsCharacterDeadPhysically(Mind mind) { // This is written explicitly so that the logic can be understood. // But it's also weird and potentially situational. // Specific considerations when updating this: // + Does being turned into a borg (if/when implemented) count as dead? // *If not, add specific conditions to users of this property where applicable.* // + Is being transformed into a donut 'dead'? // TODO: Consider changing the way ghost roles work. // Mind is an *IC* mind, therefore ghost takeover is IC revival right now. // + Is it necessary to have a reference to a specific 'mind iteration' to cycle when certain events happen? // (If being a borg or AI counts as dead, then this is highly likely, as it's still the same Mind for practical purposes.) if (mind.OwnedEntity == null) return true; // This can be null if they're deleted (spike / brain nom) var targetMobState = EntityManager.GetComponentOrNull(mind.OwnedEntity); // This can be null if it's a brain (this happens very often) // Brains are the result of gibbing so should definitely count as dead if (targetMobState == null) return true; // They might actually be alive. return _mobStateSystem.IsDead(mind.OwnedEntity.Value, targetMobState); } public void Visit(Mind mind, EntityUid entity) { if (mind.VisitingEntity != null) { Log.Error($"Attempted to visit an entity ({ToPrettyString(entity)}) while already visiting another ({ToPrettyString(mind.VisitingEntity.Value)})."); return; } if (HasComp(entity)) { Log.Error($"Attempted to visit an entity that already has a visiting mind. Entity: {ToPrettyString(entity)}"); return; } mind.Session?.AttachToEntity(entity); mind.VisitingEntity = entity; // EnsureComp instead of AddComp to deal with deferred deletions. var comp = EnsureComp(entity); comp.Mind = mind; Log.Info($"Session {mind.Session?.Name} visiting entity {entity}."); } /// /// Returns the mind to its original entity. /// public void UnVisit(Mind? mind) { if (mind == null || mind.VisitingEntity == null) return; DebugTools.Assert(mind.VisitingEntity != mind.OwnedEntity); RemoveVisitingEntity(mind); if (mind.Session == null || mind.Session.AttachedEntity == mind.VisitingEntity) return; var owned = mind.OwnedEntity; mind.Session.AttachToEntity(owned); if (owned.HasValue) { _adminLogger.Add(LogType.Mind, LogImpact.Low, $"{mind.Session.Name} returned to {ToPrettyString(owned.Value)}"); } } /// /// Cleans up the VisitingEntity. /// /// private void RemoveVisitingEntity(Mind mind) { if (mind.VisitingEntity == null) return; var oldVisitingEnt = mind.VisitingEntity.Value; // Null this before removing the component to avoid any infinite loops. mind.VisitingEntity = null; if (TryComp(oldVisitingEnt, out VisitingMindComponent? visitComp)) { visitComp.Mind = null; RemCompDeferred(oldVisitingEnt, visitComp); } RaiseLocalEvent(oldVisitingEnt, new MindUnvisitedMessage(), true); } /// /// Transfer this mind's control over to a new entity. /// /// The mind to transfer /// /// The entity to control. /// Can be null, in which case it will simply detach the mind from any entity. /// /// /// If true, skips ghost check for Visiting Entity /// /// /// Thrown if is already owned by another mind. /// public void TransferTo(Mind mind, EntityUid? entity, bool ghostCheckOverride = false) { // Looks like caller just wants us to go back to normal. if (entity == mind.OwnedEntity) { UnVisit(mind); return; } MindContainerComponent? component = null; var alreadyAttached = false; if (entity != null) { if (!TryComp(entity.Value, out component)) { component = AddComp(entity.Value); } else if (component.HasMind) { _gameTicker.OnGhostAttempt(component.Mind, false); } if (TryComp(entity.Value, out var actor)) { // Happens when transferring to your currently visited entity. if (actor.PlayerSession != mind.Session) { throw new ArgumentException("Visit target already has a session.", nameof(entity)); } alreadyAttached = true; } } var oldComp = mind.OwnedComponent; var oldEntity = mind.OwnedEntity; if(oldComp != null && oldEntity != null) InternalEjectMind(oldEntity.Value, oldComp); SetOwnedEntity(mind, entity, component); if (mind.OwnedComponent != null) InternalAssignMind(mind.OwnedEntity!.Value, mind, mind.OwnedComponent); // Don't do the full deletion cleanup if we're transferring to our VisitingEntity if (alreadyAttached) { // Set VisitingEntity null first so the removal of VisitingMind doesn't get through Unvisit() and delete what we're visiting. // Yes this control flow sucks. mind.VisitingEntity = null; RemComp(entity!.Value); } else if (mind.VisitingEntity != null && (ghostCheckOverride // to force mind transfer, for example from ControlMobVerb || !TryComp(mind.VisitingEntity!, out GhostComponent? ghostComponent) // visiting entity is not a Ghost || !ghostComponent.CanReturnToBody)) // it is a ghost, but cannot return to body anyway, so it's okay { RemoveVisitingEntity(mind); } // Player is CURRENTLY connected. if (mind.Session != null && !alreadyAttached && mind.VisitingEntity == null) { mind.Session.AttachToEntity(entity); Log.Info($"Session {mind.Session.Name} transferred to entity {entity}."); } } public void ChangeOwningPlayer(Mind mind, NetUserId? newOwner) { // Make sure to remove control from our old owner if they're logged in. var oldSession = mind.Session; oldSession?.AttachToEntity(null); if (mind.UserId.HasValue) { if (_playerManager.TryGetPlayerData(mind.UserId.Value, out var oldUncast)) { var data = oldUncast.ContentData(); DebugTools.AssertNotNull(data); data!.UpdateMindFromMindChangeOwningPlayer(null); } else { Log.Warning($"Mind UserId {newOwner} is does not exist in PlayerManager"); } } SetUserId(mind, newOwner); if (!newOwner.HasValue) { return; } if (!_playerManager.TryGetPlayerData(newOwner.Value, out var uncast)) { // This restriction is because I'm too lazy to initialize the player data // for a client that hasn't logged in yet. // Go ahead and remove it if you need. throw new ArgumentException("New owner must have previously logged into the server.", nameof(newOwner)); } // PlayerData? newOwnerData = null; var newOwnerData = uncast.ContentData(); // Yank new owner out of their old mind too. // Can I mention how much I love the word yank? DebugTools.AssertNotNull(newOwnerData); if (newOwnerData!.Mind != null) ChangeOwningPlayer(newOwnerData.Mind, null); newOwnerData.UpdateMindFromMindChangeOwningPlayer(mind); } /// /// Adds an objective to this mind. /// public bool TryAddObjective(Mind mind, ObjectivePrototype objectivePrototype) { if (!objectivePrototype.CanBeAssigned(mind)) return false; var objective = objectivePrototype.GetObjective(mind); if (mind.Objectives.Contains(objective)) return false; foreach (var condition in objective.Conditions) { _adminLogger.Add(LogType.Mind, LogImpact.Low, $"'{condition.Title}' added to mind of {MindOwnerLoggingString(mind)}"); } mind.Objectives.Add(objective); return true; } /// /// Removes an objective to this mind. /// /// Returns true if the removal succeeded. public bool TryRemoveObjective(Mind mind, int index) { if (mind.Objectives.Count >= index) return false; var objective = mind.Objectives[index]; foreach (var condition in objective.Conditions) { _adminLogger.Add(LogType.Mind, LogImpact.Low, $"'{condition.Title}' removed from the mind of {MindOwnerLoggingString(mind)}"); } mind.Objectives.Remove(objective); return true; } /// /// Gives this mind a new role. /// /// The mind to add the role to. /// The type of the role to give. /// The instance of the role. /// /// Thrown if we already have a role with this type. /// public void AddRole(Mind mind, Role role) { if (mind.Roles.Contains(role)) { throw new ArgumentException($"We already have this role: {role}"); } mind.Roles.Add(role); role.Greet(); var message = new RoleAddedEvent(mind, role); if (mind.OwnedEntity != null) { RaiseLocalEvent(mind.OwnedEntity.Value, message, true); } _adminLogger.Add(LogType.Mind, LogImpact.Low, $"'{role.Name}' added to mind of {MindOwnerLoggingString(mind)}"); } /// /// Removes a role from this mind. /// /// The mind to remove the role from. /// The type of the role to remove. /// /// Thrown if we do not have this role. /// public void RemoveRole(Mind mind, Role role) { if (!mind.Roles.Contains(role)) { throw new ArgumentException($"We do not have this role: {role}"); } mind.Roles.Remove(role); var message = new RoleRemovedEvent(mind, role); if (mind.OwnedEntity != null) { RaiseLocalEvent(mind.OwnedEntity.Value, message, true); } _adminLogger.Add(LogType.Mind, LogImpact.Low, $"'{role.Name}' removed from mind of {MindOwnerLoggingString(mind)}"); } public bool HasRole(Mind mind) where T : Role { return mind.Roles.Any(role => role is T); } public bool TryGetSession(Mind mind, [NotNullWhen(true)] out IPlayerSession? session) { return (session = mind.Session) != null; } /// /// Gets a mind from uid and/or MindContainerComponent. Used for null checks. /// /// Entity UID that owns the mind. /// The returned mind. /// Mind component on to get the mind from. /// True if mind found. False if not. public bool TryGetMind(EntityUid uid, [NotNullWhen(true)] out Mind? mind, MindContainerComponent? mindContainerComponent = null) { mind = null; if (!Resolve(uid, ref mindContainerComponent)) return false; if (!mindContainerComponent.HasMind) return false; mind = mindContainerComponent.Mind; return true; } /// /// Sets the Mind's OwnedComponent and OwnedEntity /// /// Mind to set OwnedComponent and OwnedEntity on /// Entity owned by /// MindContainerComponent owned by private void SetOwnedEntity(Mind mind, EntityUid? uid, MindContainerComponent? mindContainerComponent) { if (uid != null) Resolve(uid.Value, ref mindContainerComponent); mind.OwnedEntity = uid; mind.OwnedComponent = mindContainerComponent; } /// /// Sets the Mind's UserId and Session /// /// /// private void SetUserId(Mind mind, NetUserId? userId) { mind.UserId = userId; if (!userId.HasValue) return; _playerManager.TryGetSessionById(userId.Value, out var ret); mind.Session = ret; } /// /// True if this Mind is 'sufficiently dead' IC (Objectives, EndText). /// Note that this is *IC logic*, it's not necessarily tied to any specific truth. /// "If administrators decide that zombies are dead, this returns true for zombies." /// (Maybe you were looking for the action blocker system?) /// public bool IsCharacterDeadIc(Mind mind) { return IsCharacterDeadPhysically(mind); } /// /// A string to represent the mind for logging /// private string MindOwnerLoggingString(Mind mind) { if (mind.OwnedEntity != null) return ToPrettyString(mind.OwnedEntity.Value); if (mind.UserId != null) return mind.UserId.Value.ToString(); return "(originally " + mind.OriginalOwnerUserId + ")"; } }