Files
Delta-v/Content.Server/Ghost/GhostSystem.cs
T
0deeaf7b77 Better Admin Tooling and New Curator Toys (#4577)
* Add Pockets and Tail-Drag to Admin Ghosts (#1508)

Adds 4 pockets to the Admin Ghosts and the ability to Tail-Drag (no
hands needed).

---

https://github.com/user-attachments/assets/701b66bd-c42c-429f-a33a-f8e465ef068d

---

🆑
ADMIN:
- add: Added pockets to Admin Ghosts.
- add: Added tail-drag (no-hands) to Admin Ghosts.

* Adding Admin Tools and Changing Aghost Starting Gear (#855)

- Added the admin folder, which can hold up to any normal item.
Prefilled with paper and a tasty treat.
- Added the admin toolbelt, which can hold up to any normal item.
Prefilled with various tools.
- Changed the admin satchel to be removable and added a few things to
its fill.
- Added the Admin RCD, an infinite RCD.
- Added an admin material box which contains 6 of the most basic
materials.
- The ID card in the admin PDA is now a CC ID card.

People were complaining about a lack of basic tools on the aghost.

Simple yml changes.

![image](https://github.com/user-attachments/assets/a65b145b-41d8-41e8-b48f-0ed567febe29)

- [x] I have read and am following the [Pull Request and Changelog
Guidelines](https://docs.spacestation14.com/en/general-development/codebase-info/pull-request-guidelines.html).
- [x] I have added media to this PR or it does not require an ingame
showcase.
- [x] I can confirm this PR contains no AI-generated content, and did
not use any AI-generated content.

None afaik.

**Changelog**
🆑
- add: A bunch of new admin tools and things in the aghost starting
gear.

---------

Co-authored-by: TheDen-Bot <186991657+TheDen-Bot@users.noreply.github.com>

* Removed admin folder because it was being weird when put into containers and I don't really want fix it.

* Added auto-color for aghosts

* Added suffix to the CentComm implanter

* BSO Lifeline Port (#1730)

BSO lifeline implant - ported from Goobstation.

What is it?: It's a bluespace implant. Triggers when you die or when you
click the button twice.

Why is it?: BSO is a special role, specifically employed by CC. They
exist on station only to protect to command and nothing else. They have
pretty strong armour and weapons. So this is the solution to that +
creates an RP narrative.

It spawns in the BSO's pocket, as it's an optional implant, should
probably be taken either start of shift or when things get dicey for
command.

Additional: Adds new sprites for CC specified implants.

---

<details><summary><h1>Media</h1></summary>
<p>

![Content
Client_MHAiLxtLug](https://github.com/user-attachments/assets/907b8a0b-98af-4a1c-855b-e1737a373e0d)

</p>
</details>

---

🆑
- add: Blueshield Officer lifeline implanter

---------

Signed-off-by: Rouge2t7 <81053047+Sarahon@users.noreply.github.com>

* Fixed admin ghost color issue.

* Re-ordered the CentDrobe so useful things are at the top.

* Changed the sprite of the CentComm mindshield to match that of the Bluespace Lifeline.

* Added the ability to select a job with the 'spawncharacter' admin command.

* Added spawnplayer admin command

* Client-Side Entity Log Sorting (#1624)

<!-- Guidelines:
https://docs.spacestation14.io/en/getting-started/pr-guideline -->
<!-- NOTE: All code submitted to this repository is ALWAYS licensed
under the AGPL-3.0-or-later license.
The REUSE Specification headers or separate .license files indicate a
secondary license (e.g., MPL or MIT) solely to facilitate
integration for projects that do not use the AGPL license. This
secondary license does not replace the fact that AGPL-3.0-or-later
remains the primary and binding license.
Uncomment and modify the following line if you wish to change the
license from the default of AGPL.-->
<!--- LICENSE: AGPL -->
## About the PR
<!-- What did you change? -->
Expanded on admin log sorting. Fixes the issues from #1569

## Why / Balance
<!-- Discuss how this would affect game balance or explain why it was
changed. Link any relevant discussions or issues. -->
Admin things.

## Technical details
<!-- Summary of code changes for easier review. -->

## Media
<!-- Attach media if the PR makes ingame changes (clothing, items,
features, etc).
Small fixes/refactors are exempt. Media may be used in SS14 progress
reports with credit. -->


https://github.com/user-attachments/assets/837d9e4e-09bb-44ac-b79f-59435edc3aed


## Requirements
<!-- Confirm the following by placing an X in the brackets [X]: -->
- [x] I have read and am following the [Pull Request and Changelog
Guidelines](https://docs.spacestation14.com/en/general-development/codebase-info/pull-request-guidelines.html).
- [x] I have added media to this PR or it does not require an ingame
showcase.
- [x] I have tested any changes or additions.
- [x] I can confirm this PR contains no AI-generated content, and did
not use any AI-generated content.
<!-- You should understand that not following the above may get your PR
closed at maintainer’s discretion -->

## Breaking changes
<!-- List any breaking changes, including namespaces, public
class/method/field changes, prototype renames; and provide instructions
for fixing them.
This will be posted in #codebase-changes. -->

**Changelog**
<!-- Add a Changelog entry to make players aware of new features or
changes that could affect gameplay.
Make sure to read the guidelines and take this Changelog template out of
the comment block in order for it to show up.
Changelog must have a 🆑 symbol, so the bot recognizes the changes and
adds them to the game's changelog. -->
🆑
- tweak: ADMIN: You can sort entity logs by pressing fancy buttons!

* Fixed YAML linting issues

* Moved bluespace lifeline textures to proper directory

* Added emergency medical pods from Frontier for admemes.

* appeased the unit test gods

* Another test fail because people forgot that you can't divide by zero.

* probably appeased the unit test gods this time

* PR #4577 Feedback

---------

Signed-off-by: Rouge2t7 <81053047+Sarahon@users.noreply.github.com>
Co-authored-by: John Willis <143434770+CerberusWolfie@users.noreply.github.com>
Co-authored-by: MajorMoth <61519600+MajorMoth@users.noreply.github.com>
Co-authored-by: TheDen-Bot <186991657+TheDen-Bot@users.noreply.github.com>
Co-authored-by: Rouge2t7 <81053047+Sarahon@users.noreply.github.com>
Co-authored-by: William Lemon <William.Lemon2@gmail.com>
2025-10-30 01:52:30 +00:00

655 lines
27 KiB
C#

using System.Linq;
using System.Numerics;
using Content.Server.Administration.Managers; // DeltaV
using Content.Server.Administration.Logs;
using Content.Server.Chat.Managers;
using Content.Server.GameTicking;
using Content.Server.Ghost.Components;
using Content.Server.Mind;
using Content.Server.Roles.Jobs;
using Content.Server.Warps;
using Content.Shared.Actions;
using Content.Shared.CCVar;
using Content.Shared.Damage;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Database;
using Content.Shared.Examine;
using Content.Shared.Eye;
using Content.Shared.FixedPoint;
using Content.Shared.Follower;
using Content.Shared.Ghost;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;
using Content.Shared.Mobs.Systems;
using Content.Shared.Movement.Events;
using Content.Shared.Movement.Systems;
using Content.Shared.NameModifier.EntitySystems;
using Content.Shared.Popups;
using Content.Server.Preferences.Managers; // DeltaV
using Content.Shared.Storage.Components;
using Content.Shared.Tag;
using Content.Shared.Warps;
using Robust.Server.GameObjects;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Map;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Server.Ghost
{
public sealed class GhostSystem : SharedGhostSystem
{
[Dependency] private readonly SharedActionsSystem _actions = default!;
[Dependency] private readonly IAdminLogManager _adminLog = default!;
[Dependency] private readonly SharedEyeSystem _eye = default!;
[Dependency] private readonly FollowerSystem _followerSystem = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly JobSystem _jobs = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly MindSystem _minds = default!;
[Dependency] private readonly MobStateSystem _mobState = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly ISharedPlayerManager _player = default!;
[Dependency] private readonly TransformSystem _transformSystem = default!;
[Dependency] private readonly VisibilitySystem _visibilitySystem = default!;
[Dependency] private readonly MetaDataSystem _metaData = default!;
[Dependency] private readonly MobThresholdSystem _mobThresholdSystem = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly SharedMindSystem _mind = default!;
[Dependency] private readonly GameTicker _gameTicker = default!;
[Dependency] private readonly DamageableSystem _damageable = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly TagSystem _tag = default!;
[Dependency] private readonly NameModifierSystem _nameMod = default!;
[Dependency] private readonly IServerPreferencesManager _preferencesManager = default!; // DeltaV
[Dependency] private readonly IAdminManager _admin = default!; // DeltaV
private EntityQuery<GhostComponent> _ghostQuery;
private EntityQuery<PhysicsComponent> _physicsQuery;
private static readonly ProtoId<TagPrototype> AllowGhostShownByEventTag = "AllowGhostShownByEvent";
public override void Initialize()
{
base.Initialize();
_ghostQuery = GetEntityQuery<GhostComponent>();
_physicsQuery = GetEntityQuery<PhysicsComponent>();
SubscribeLocalEvent<GhostComponent, ComponentStartup>(OnGhostStartup);
SubscribeLocalEvent<GhostComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<GhostComponent, ComponentShutdown>(OnGhostShutdown);
SubscribeLocalEvent<GhostComponent, ExaminedEvent>(OnGhostExamine);
SubscribeLocalEvent<GhostComponent, MindRemovedMessage>(OnMindRemovedMessage);
SubscribeLocalEvent<GhostComponent, MindUnvisitedMessage>(OnMindUnvisitedMessage);
SubscribeLocalEvent<GhostComponent, PlayerDetachedEvent>(OnPlayerDetached);
SubscribeLocalEvent<GhostOnMoveComponent, MoveInputEvent>(OnRelayMoveInput);
SubscribeNetworkEvent<GhostWarpsRequestEvent>(OnGhostWarpsRequest);
SubscribeNetworkEvent<GhostReturnToBodyRequest>(OnGhostReturnToBodyRequest);
SubscribeNetworkEvent<GhostWarpToTargetRequestEvent>(OnGhostWarpToTargetRequest);
SubscribeNetworkEvent<GhostnadoRequestEvent>(OnGhostnadoRequest);
SubscribeLocalEvent<GhostComponent, BooActionEvent>(OnActionPerform);
SubscribeLocalEvent<GhostComponent, ToggleGhostHearingActionEvent>(OnGhostHearingAction);
SubscribeLocalEvent<GhostComponent, InsertIntoEntityStorageAttemptEvent>(OnEntityStorageInsertAttempt);
SubscribeLocalEvent<RoundEndTextAppendEvent>(_ => MakeVisible(true));
SubscribeLocalEvent<ToggleGhostVisibilityToAllEvent>(OnToggleGhostVisibilityToAll);
SubscribeLocalEvent<GhostComponent, GetVisMaskEvent>(OnGhostVis);
}
private void OnGhostVis(Entity<GhostComponent> ent, ref GetVisMaskEvent args)
{
// If component not deleting they can see ghosts.
if (ent.Comp.LifeStage <= ComponentLifeStage.Running)
{
args.VisibilityMask |= (int)VisibilityFlags.Ghost;
// Begin DeltaV additions
args.VisibilityMask |= (int)VisibilityFlags.TelegnosticProjection;
args.VisibilityMask |= (int)VisibilityFlags.PsionicInvisibility;
args.VisibilityMask |= (int)VisibilityFlags.CosmicCultMonument;
// End DeltaV additions
}
}
private void OnGhostHearingAction(EntityUid uid, GhostComponent component, ToggleGhostHearingActionEvent args)
{
args.Handled = true;
if (HasComp<GhostHearingComponent>(uid))
{
RemComp<GhostHearingComponent>(uid);
_actions.SetToggled(component.ToggleGhostHearingActionEntity, true);
}
else
{
AddComp<GhostHearingComponent>(uid);
_actions.SetToggled(component.ToggleGhostHearingActionEntity, false);
}
var str = HasComp<GhostHearingComponent>(uid)
? Loc.GetString("ghost-gui-toggle-hearing-popup-on")
: Loc.GetString("ghost-gui-toggle-hearing-popup-off");
Popup.PopupEntity(str, uid, uid);
Dirty(uid, component);
}
private void OnActionPerform(EntityUid uid, GhostComponent component, BooActionEvent args)
{
if (args.Handled)
return;
var entities = _lookup.GetEntitiesInRange(args.Performer, component.BooRadius).ToList();
// Shuffle the possible targets so we don't favor any particular entities
_random.Shuffle(entities);
var booCounter = 0;
foreach (var ent in entities)
{
var handled = DoGhostBooEvent(ent);
if (handled)
booCounter++;
if (booCounter >= component.BooMaxTargets)
break;
}
if (booCounter == 0)
_popup.PopupEntity(Loc.GetString("ghost-component-boo-action-failed"), uid, uid);
args.Handled = true;
}
private void OnRelayMoveInput(EntityUid uid, GhostOnMoveComponent component, ref MoveInputEvent args)
{
// If they haven't actually moved then ignore it.
if ((args.Entity.Comp.HeldMoveButtons &
(MoveButtons.Down | MoveButtons.Left | MoveButtons.Up | MoveButtons.Right)) == 0x0)
{
return;
}
// Let's not ghost if our mind is visiting...
if (HasComp<VisitingMindComponent>(uid))
return;
if (!_minds.TryGetMind(uid, out var mindId, out var mind) || mind.IsVisitingEntity)
return;
if (component.MustBeDead && (_mobState.IsAlive(uid) || _mobState.IsCritical(uid)))
return;
OnGhostAttempt(mindId, component.CanReturn, mind: mind);
}
private void OnGhostStartup(EntityUid uid, GhostComponent component, ComponentStartup args)
{
// Allow this entity to be seen by other ghosts.
var visibility = EnsureComp<VisibilityComponent>(uid);
if (_gameTicker.RunLevel != GameRunLevel.PostRound)
{
_visibilitySystem.AddLayer((uid, visibility), (int) VisibilityFlags.Ghost, false);
_visibilitySystem.RemoveLayer((uid, visibility), (int) VisibilityFlags.Normal, false);
_visibilitySystem.RefreshVisibility(uid, visibilityComponent: visibility);
}
_eye.RefreshVisibilityMask(uid);
var time = _gameTiming.CurTime;
component.TimeOfDeath = time;
}
private void OnGhostShutdown(EntityUid uid, GhostComponent component, ComponentShutdown args)
{
// Perf: If the entity is deleting itself, no reason to change these back.
if (Terminating(uid))
return;
// Entity can't be seen by ghosts anymore.
if (TryComp(uid, out VisibilityComponent? visibility))
{
_visibilitySystem.RemoveLayer((uid, visibility), (int) VisibilityFlags.Ghost, false);
_visibilitySystem.AddLayer((uid, visibility), (int) VisibilityFlags.Normal, false);
_visibilitySystem.RefreshVisibility(uid, visibilityComponent: visibility);
}
// Entity can't see ghosts anymore.
_eye.RefreshVisibilityMask(uid);
_actions.RemoveAction(uid, component.BooActionEntity);
}
private void OnMapInit(EntityUid uid, GhostComponent component, MapInitEvent args)
{
_actions.AddAction(uid, ref component.BooActionEntity, component.BooAction);
_actions.AddAction(uid, ref component.ToggleGhostHearingActionEntity, component.ToggleGhostHearingAction);
_actions.AddAction(uid, ref component.ToggleLightingActionEntity, component.ToggleLightingAction);
_actions.AddAction(uid, ref component.ToggleFoVActionEntity, component.ToggleFoVAction);
_actions.AddAction(uid, ref component.ToggleGhostsActionEntity, component.ToggleGhostsAction);
}
private void OnGhostExamine(EntityUid uid, GhostComponent component, ExaminedEvent args)
{
var timeSinceDeath = _gameTiming.RealTime.Subtract(component.TimeOfDeath);
var deathTimeInfo = timeSinceDeath.Minutes > 0
? Loc.GetString("comp-ghost-examine-time-minutes", ("minutes", timeSinceDeath.Minutes))
: Loc.GetString("comp-ghost-examine-time-seconds", ("seconds", timeSinceDeath.Seconds));
args.PushMarkup(deathTimeInfo);
}
#region Ghost Deletion
private void OnMindRemovedMessage(EntityUid uid, GhostComponent component, MindRemovedMessage args)
{
DeleteEntity(uid);
}
private void OnMindUnvisitedMessage(EntityUid uid, GhostComponent component, MindUnvisitedMessage args)
{
DeleteEntity(uid);
}
private void OnPlayerDetached(EntityUid uid, GhostComponent component, PlayerDetachedEvent args)
{
DeleteEntity(uid);
}
private void DeleteEntity(EntityUid uid)
{
if (Deleted(uid) || Terminating(uid))
return;
QueueDel(uid);
}
#endregion
private void OnGhostReturnToBodyRequest(GhostReturnToBodyRequest msg, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not {Valid: true} attached
|| !_ghostQuery.TryComp(attached, out var ghost)
|| !ghost.CanReturnToBody
|| !TryComp(attached, out ActorComponent? actor))
{
Log.Warning($"User {args.SenderSession.Name} sent an invalid {nameof(GhostReturnToBodyRequest)}");
return;
}
_mind.UnVisit(actor.PlayerSession);
}
#region Warp
private void OnGhostWarpsRequest(GhostWarpsRequestEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not {Valid: true} entity
|| !_ghostQuery.HasComp(entity))
{
Log.Warning($"User {args.SenderSession.Name} sent a {nameof(GhostWarpsRequestEvent)} without being a ghost.");
return;
}
var response = new GhostWarpsResponseEvent(GetPlayerWarps(entity).Concat(GetLocationWarps()).ToList());
RaiseNetworkEvent(response, args.SenderSession.Channel);
}
private void OnGhostWarpToTargetRequest(GhostWarpToTargetRequestEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not {Valid: true} attached
|| !_ghostQuery.HasComp(attached))
{
Log.Warning($"User {args.SenderSession.Name} tried to warp to {msg.Target} without being a ghost.");
return;
}
var target = GetEntity(msg.Target);
if (!Exists(target))
{
Log.Warning($"User {args.SenderSession.Name} tried to warp to an invalid entity id: {msg.Target}");
return;
}
WarpTo(attached, target);
}
private void OnGhostnadoRequest(GhostnadoRequestEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not {} uid
|| !_ghostQuery.HasComp(uid))
{
Log.Warning($"User {args.SenderSession.Name} tried to ghostnado without being a ghost.");
return;
}
if (_followerSystem.GetMostGhostFollowed() is not {} target)
return;
WarpTo(uid, target);
}
private void WarpTo(EntityUid uid, EntityUid target)
{
_adminLog.Add(LogType.GhostWarp, $"{ToPrettyString(uid)} ghost warped to {ToPrettyString(target)}");
if ((TryComp(target, out WarpPointComponent? warp) && warp.Follow) || HasComp<MobStateComponent>(target))
{
_followerSystem.StartFollowingEntity(uid, target);
return;
}
var xform = Transform(uid);
_transformSystem.SetCoordinates(uid, xform, Transform(target).Coordinates);
_transformSystem.AttachToGridOrMap(uid, xform);
if (_physicsQuery.TryComp(uid, out var physics))
_physics.SetLinearVelocity(uid, Vector2.Zero, body: physics);
}
private IEnumerable<GhostWarp> GetLocationWarps()
{
var allQuery = AllEntityQuery<WarpPointComponent>();
while (allQuery.MoveNext(out var uid, out var warp))
{
yield return new GhostWarp(GetNetEntity(uid), warp.Location ?? Name(uid), true);
}
}
private IEnumerable<GhostWarp> GetPlayerWarps(EntityUid except)
{
foreach (var player in _player.Sessions)
{
if (player.AttachedEntity is not {Valid: true} attached)
continue;
if (attached == except) continue;
TryComp<MindContainerComponent>(attached, out var mind);
var jobName = _jobs.MindTryGetJobName(mind?.Mind);
var playerInfo = $"{Comp<MetaDataComponent>(attached).EntityName} ({jobName})";
if (_mobState.IsAlive(attached) || _mobState.IsCritical(attached))
yield return new GhostWarp(GetNetEntity(attached), playerInfo, false);
}
}
#endregion
private void OnEntityStorageInsertAttempt(EntityUid uid, GhostComponent comp, ref InsertIntoEntityStorageAttemptEvent args)
{
args.Cancelled = true;
}
private void OnToggleGhostVisibilityToAll(ToggleGhostVisibilityToAllEvent ev)
{
if (ev.Handled)
return;
ev.Handled = true;
MakeVisible(true);
}
/// <summary>
/// When the round ends, make all players able to see ghosts.
/// </summary>
public void MakeVisible(bool visible)
{
var entityQuery = EntityQueryEnumerator<GhostComponent, VisibilityComponent>();
while (entityQuery.MoveNext(out var uid, out var _, out var vis))
{
if (!_tag.HasTag(uid, AllowGhostShownByEventTag))
continue;
if (visible)
{
_visibilitySystem.AddLayer((uid, vis), (int) VisibilityFlags.Normal, false);
_visibilitySystem.RemoveLayer((uid, vis), (int) VisibilityFlags.Ghost, false);
}
else
{
_visibilitySystem.AddLayer((uid, vis), (int) VisibilityFlags.Ghost, false);
_visibilitySystem.RemoveLayer((uid, vis), (int) VisibilityFlags.Normal, false);
}
_visibilitySystem.RefreshVisibility(uid, visibilityComponent: vis);
}
}
public bool DoGhostBooEvent(EntityUid target)
{
var ghostBoo = new GhostBooEvent();
RaiseLocalEvent(target, ghostBoo, true);
return ghostBoo.Handled;
}
public EntityUid? SpawnGhost(Entity<MindComponent?> mind, EntityUid targetEntity,
bool canReturn = false)
{
_transformSystem.TryGetMapOrGridCoordinates(targetEntity, out var spawnPosition);
return SpawnGhost(mind, spawnPosition, canReturn);
}
private bool IsValidSpawnPosition(EntityCoordinates? spawnPosition)
{
if (spawnPosition?.IsValid(EntityManager) != true)
return false;
var mapUid = _transformSystem.GetMap(spawnPosition.Value);
var gridUid = spawnPosition?.EntityId;
// Test if the map is being deleted
if (mapUid == null || TerminatingOrDeleted(mapUid.Value))
return false;
// Test if the grid is being deleted
if (gridUid != null && TerminatingOrDeleted(gridUid.Value))
return false;
return true;
}
public EntityUid? SpawnGhost(Entity<MindComponent?> mind, EntityCoordinates? spawnPosition = null,
bool canReturn = false)
{
if (!Resolve(mind, ref mind.Comp))
return null;
// Test if the map or grid is being deleted
if (!IsValidSpawnPosition(spawnPosition))
spawnPosition = null;
// If it's bad, look for a valid point to spawn
spawnPosition ??= _gameTicker.GetObserverSpawnPoint();
// Make sure the new point is valid too
if (!IsValidSpawnPosition(spawnPosition))
{
Log.Warning($"No spawn valid ghost spawn position found for {mind.Comp.CharacterName}"
+ $" \"{ToPrettyString(mind)}\"");
_minds.TransferTo(mind.Owner, null, createGhost: false, mind: mind.Comp);
return null;
}
var ghost = SpawnAtPosition(GameTicker.ObserverPrototypeName, spawnPosition.Value);
var ghostComponent = Comp<GhostComponent>(ghost);
// Try setting the ghost entity name to either the character name or the player name.
// If all else fails, it'll default to the default entity prototype name, "observer".
// However, that should rarely happen.
if (!string.IsNullOrWhiteSpace(mind.Comp.CharacterName))
_metaData.SetEntityName(ghost, mind.Comp.CharacterName);
else if (mind.Comp.UserId is { } userId && _player.TryGetSessionById(userId, out var session))
_metaData.SetEntityName(ghost, session.Name);
if (mind.Comp.TimeOfDeath.HasValue)
{
SetTimeOfDeath((ghost, ghostComponent), mind.Comp.TimeOfDeath!.Value);
}
SetCanReturnToBody((ghost, ghostComponent), canReturn);
if (canReturn)
_minds.Visit(mind.Owner, ghost, mind.Comp);
else
_minds.TransferTo(mind.Owner, ghost, mind: mind.Comp);
Log.Debug($"Spawned ghost \"{ToPrettyString(ghost)}\" for {mind.Comp.CharacterName}.");
// we changed the entity name above
// we have to call this after the mind has been transferred since some mind roles modify the ghost's name
_nameMod.RefreshNameModifiers(ghost);
ApplyAdminOOCColor(ghost); // DeltaV
return ghost;
}
public bool OnGhostAttempt(EntityUid mindId, bool canReturnGlobal, bool viaCommand = false, bool forced = false, MindComponent? mind = null)
{
if (!Resolve(mindId, ref mind))
return false;
var playerEntity = mind.CurrentEntity;
if (playerEntity != null && viaCommand)
{
if (forced)
_adminLog.Add(LogType.Mind, $"{EntityManager.ToPrettyString(playerEntity.Value):player} was forced to ghost via command");
else
_adminLog.Add(LogType.Mind, $"{EntityManager.ToPrettyString(playerEntity.Value):player} is attempting to ghost via command");
}
var handleEv = new GhostAttemptHandleEvent(mind, canReturnGlobal);
RaiseLocalEvent(handleEv);
// Something else has handled the ghost attempt for us! We return its result.
if (handleEv.Handled)
return handleEv.Result;
if (mind.PreventGhosting && !forced)
{
if (_player.TryGetSessionById(mind.UserId, out var session)) // Logging is suppressed to prevent spam from ghost attempts caused by movement attempts
{
_chatManager.DispatchServerMessage(session, Loc.GetString("comp-mind-ghosting-prevented"),
true);
}
return false;
}
if (TryComp<GhostComponent>(playerEntity, out var comp) && !comp.CanGhostInteract)
return false;
if (mind.VisitingEntity != default)
{
_mind.UnVisit(mindId, mind: mind);
}
var position = Exists(playerEntity)
? Transform(playerEntity.Value).Coordinates
: _gameTicker.GetObserverSpawnPoint();
if (position == default)
return false;
// Ok, so, this is the master place for the logic for if ghosting is "too cheaty" to allow returning.
// There's no reason at this time to move it to any other place, especially given that the 'side effects required' situations would also have to be moved.
// + If CharacterDeadPhysically applies, we're physically dead. Therefore, ghosting OK, and we can return (this is critical for gibbing)
// Note that we could theoretically be ICly dead and still physically alive and vice versa.
// (For example, a zombie could be dead ICly, but may retain memories and is definitely physically active)
// + If we're in a mob that is critical, and we're supposed to be able to return if possible,
// we're succumbing - the mob is killed. Therefore, character is dead. Ghosting OK.
// (If the mob survives, that's a bug. Ghosting is kept regardless.)
var canReturn = canReturnGlobal && _mind.IsCharacterDeadPhysically(mind);
if (_configurationManager.GetCVar(CCVars.GhostKillCrit) &&
canReturnGlobal &&
TryComp(playerEntity, out MobStateComponent? mobState))
{
if (_mobState.IsCritical(playerEntity.Value, mobState))
{
canReturn = true;
//todo: what if they dont breathe lol
//cry deeply
FixedPoint2 dealtDamage = 200;
if (TryComp<DamageableComponent>(playerEntity, out var damageable)
&& TryComp<MobThresholdsComponent>(playerEntity, out var thresholds))
{
var playerDeadThreshold = _mobThresholdSystem.GetThresholdForState(playerEntity.Value, MobState.Dead, thresholds);
dealtDamage = playerDeadThreshold - damageable.TotalDamage;
}
DamageSpecifier damage = new(_prototypeManager.Index<DamageTypePrototype>("Asphyxiation"), dealtDamage);
_damageable.TryChangeDamage(playerEntity, damage, true);
}
}
if (playerEntity != null)
_adminLog.Add(LogType.Mind, $"{EntityManager.ToPrettyString(playerEntity.Value):player} ghosted{(!canReturn ? " (non-returnable)" : "")}");
var ghost = SpawnGhost((mindId, mind), position, canReturn);
if (ghost == null)
return false;
return true;
}
/// <summary>
/// DeltaV - Applies the admin OOC color to a ghost entity if the player has one set.
/// </summary>
/// <param name="ghostEntity">The ghost entity to apply the color to</param>
/// <param name="mindId">The mind ID of the player</param>
public void ApplyAdminOOCColor(EntityUid ghostEntity) // Mono
{
if (!_player.TryGetSessionByEntity(ghostEntity, out var session))
return;
// Only apply admin OOC color if the player is actually an admin
if (!_admin.IsAdmin(session))
return;
if (!_preferencesManager.TryGetCachedPreferences(session.UserId, out var prefs))
return;
// Only apply the color if it's not transparent (the default)
if (prefs.AdminOOCColor == Color.Transparent)
return;
// Make the color slightly transparent for ghosts
var ghostColor = prefs.AdminOOCColor;
if (TryComp<GhostComponent>(ghostEntity, out var ghostComp))
{
ghostComp.Color = ghostColor;
Dirty(ghostEntity, ghostComp);
}
}
}
public sealed class GhostAttemptHandleEvent(MindComponent mind, bool canReturnGlobal) : HandledEntityEventArgs
{
public MindComponent Mind { get; } = mind;
public bool CanReturnGlobal { get; } = canReturnGlobal;
public bool Result { get; set; }
}
}