using System.Globalization; using System.Linq; using System.Numerics; using Content.Server.Administration.Managers; using Content.Server.Administration.Systems; using Content.Server.GameTicking.Events; using Content.Server.Spawners.Components; using Content.Server.Speech.Components; using Content.Server.Station.Components; using Content.Shared.Database; using Content.Shared.GameTicking; using Content.Shared.Mind; using Content.Shared.Players; using Content.Shared.Preferences; using Content.Shared.Roles; using Content.Shared.Roles.Jobs; using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Utility; using Robust.Shared.Timing; using Content.Server.GameTicking.Rules.Components; using Content.Shared.GameTicking.Components; namespace Content.Server.GameTicking { public sealed partial class GameTicker { [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly SharedJobSystem _jobs = default!; [Dependency] private readonly AdminSystem _admin = default!; [ValidatePrototypeId] public const string ObserverPrototypeName = "MobObserver"; [ValidatePrototypeId] public const string AdminObserverPrototypeName = "AdminObserver"; /// /// How many players have joined the round through normal methods. /// Useful for game rules to look at. Doesn't count observers, people in lobby, etc. /// public int PlayersJoinedRoundNormally; // Mainly to avoid allocations. private readonly List _possiblePositions = new(); private List GetSpawnableStations() { var spawnableStations = new List(); var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out _, out _)) { spawnableStations.Add(uid); } return spawnableStations; } private void SpawnPlayers(List readyPlayers, Dictionary profiles, bool force) { // Allow game rules to spawn players by themselves if needed. (For example, nuke ops or wizard) RaiseLocalEvent(new RulePlayerSpawningEvent(readyPlayers, profiles, force)); var playerNetIds = readyPlayers.Select(o => o.UserId).ToHashSet(); // RulePlayerSpawning feeds a readonlydictionary of profiles. // We need to take these players out of the pool of players available as they've been used. if (readyPlayers.Count != profiles.Count) { var toRemove = new RemQueue(); foreach (var (player, _) in profiles) { if (playerNetIds.Contains(player)) continue; toRemove.Add(player); } foreach (var player in toRemove) { profiles.Remove(player); } } var spawnableStations = GetSpawnableStations(); var assignedJobs = _stationJobs.AssignJobs(profiles, spawnableStations); _stationJobs.AssignOverflowJobs(ref assignedJobs, playerNetIds, profiles, spawnableStations); // Calculate extended access for stations. var stationJobCounts = spawnableStations.ToDictionary(e => e, _ => 0); foreach (var (netUser, (job, station)) in assignedJobs) { if (job == null) { var playerSession = _playerManager.GetSessionById(netUser); var evNoJobs = new NoJobsAvailableSpawningEvent(playerSession); // Used by gamerules to wipe their antag slot, if they got one RaiseLocalEvent(evNoJobs); _chatManager.DispatchServerMessage(playerSession, Loc.GetString("job-not-available-wait-in-lobby")); } else { stationJobCounts[station] += 1; } } _stationJobs.CalcExtendedAccess(stationJobCounts); // Spawn everybody in! foreach (var (player, (job, station)) in assignedJobs) { if (job == null) continue; SpawnPlayer(_playerManager.GetSessionById(player), profiles[player], station, job, false); } RefreshLateJoinAllowed(); // Allow rules to add roles to players who have been spawned in. (For example, on-station traitors) RaiseLocalEvent(new RulePlayerJobsAssignedEvent( assignedJobs.Keys.Select(x => _playerManager.GetSessionById(x)).ToArray(), profiles, force)); } private void SpawnPlayer(ICommonSession player, EntityUid station, string? jobId = null, bool lateJoin = true, bool silent = false) { var character = GetPlayerProfile(player); var jobBans = _banManager.GetJobBans(player.UserId); if (jobBans == null || jobId != null && jobBans.Contains(jobId)) return; if (jobId != null) { var ev = new IsJobAllowedEvent(player, new ProtoId(jobId)); RaiseLocalEvent(ref ev); if (ev.Cancelled) return; } foreach (var tracker in EntityQuery()) { foreach (var (player1, time) in tracker.RespawnQueue) { if (player1 == player.UserId) { if (!tracker.Fixed) { if (_gameTiming.CurTime < time) { _chatManager.DispatchServerMessage(player, Loc.GetString("rule-respawn-blocked", ("seconds", Math.Ceiling(time.TotalSeconds - _gameTiming.CurTime.TotalSeconds)))); return; } } else { if (_gameTiming.CurTime < tracker.GlobalTimer) { _chatManager.DispatchServerMessage(player, Loc.GetString("rule-respawn-blocked", ("seconds", Math.Ceiling(tracker.GlobalTimer.TotalSeconds - _gameTiming.CurTime.TotalSeconds)))); return; } } } } } //if TDM, check if the teams are balanced var factionCount = GetPlayerFactionCounts(); if (factionCount != null && factionCount.Count > 1) { //get the faction of the selected job if (jobId != null && _prototypeManager.TryIndex(jobId, out var job)) { var selectedFaction = job.Faction; var currentCount = 0; var minCount = 1000; foreach (var fact in factionCount) { if (fact.Key == selectedFaction) { currentCount = fact.Value; } else if (fact.Key != selectedFaction && fact.Value < minCount && fact.Key != "UnitedNations") { minCount = fact.Value; } } if (currentCount > minCount) { //if the current faction is greater than the minimum faction, block the respawn _chatManager.DispatchServerMessage(player, Loc.GetString("rule-respawn-autobalance", ("this", currentCount), ("other", minCount))); return; } } } SpawnPlayer(player, character, station, jobId, lateJoin, silent); } private void SpawnPlayer(ICommonSession player, HumanoidCharacterProfile character, EntityUid station, string? jobId = null, bool lateJoin = true, bool silent = false) { // Can't spawn players with a dummy ticker! if (DummyTicker) return; if (station == EntityUid.Invalid) { var stations = GetSpawnableStations(); _robustRandom.Shuffle(stations); if (stations.Count == 0) station = EntityUid.Invalid; else station = stations[0]; } if (lateJoin && DisallowLateJoin) { JoinAsObserver(player); return; } // We raise this event to allow other systems to handle spawning this player themselves. (e.g. late-join wizard, etc) var bev = new PlayerBeforeSpawnEvent(player, character, jobId, lateJoin, station); RaiseLocalEvent(bev); // Do nothing, something else has handled spawning this player for us! if (bev.Handled) { PlayerJoinGame(player, silent); return; } // Figure out job restrictions var restrictedRoles = new HashSet>(); var ev = new GetDisallowedJobsEvent(player, restrictedRoles); RaiseLocalEvent(ref ev); var jobBans = _banManager.GetJobBans(player.UserId); if (jobBans != null) restrictedRoles.UnionWith(jobBans); // Pick best job best on prefs. jobId ??= _stationJobs.PickBestAvailableJobWithPriority(station, character.JobPriorities, true, restrictedRoles); // If no job available, stay in lobby, or if no lobby spawn as observer if (jobId is null) { if (!LobbyEnabled) { JoinAsObserver(player); } if (LobbyEnabled) { PlayerJoinLobby(player); } var evNoJobs = new NoJobsAvailableSpawningEvent(player); // Used by gamerules to wipe their antag slot, if they got one RaiseLocalEvent(evNoJobs); _chatManager.DispatchServerMessage(player, Loc.GetString("game-ticker-player-no-jobs-available-when-joining")); return; } PlayerJoinGame(player, silent); var data = player.ContentData(); DebugTools.AssertNotNull(data); var newMind = _mind.CreateMind(data!.UserId, character.Name); _mind.SetUserId(newMind, data.UserId); var jobPrototype = _prototypeManager.Index(jobId); _playTimeTrackings.PlayerRolesChanged(player); var mobMaybe = _stationSpawning.SpawnPlayerCharacterOnStation(station, jobId, character); DebugTools.AssertNotNull(mobMaybe); var mob = mobMaybe!.Value; _mind.TransferTo(newMind, mob); _roles.MindAddJobRole(newMind, silent: silent, jobPrototype: jobId); var jobName = _jobs.MindTryGetJobName(newMind); _admin.UpdatePlayerList(player); if (lateJoin && !silent) { if (jobPrototype.JoinNotifyCrew) { _chatSystem.DispatchStationAnnouncement(station, Loc.GetString("latejoin-arrival-announcement-special", ("character", MetaData(mob).EntityName), ("entity", mob), ("job", CultureInfo.CurrentCulture.TextInfo.ToTitleCase(jobName))), Loc.GetString("latejoin-arrival-sender"), playDefaultSound: false, colorOverride: Color.Gold); } else { _chatSystem.DispatchStationAnnouncement(station, Loc.GetString("latejoin-arrival-announcement", ("character", MetaData(mob).EntityName), ("entity", mob), ("job", CultureInfo.CurrentCulture.TextInfo.ToTitleCase(jobName))), Loc.GetString("latejoin-arrival-sender"), playDefaultSound: false); } } if (player.UserId == new Guid("{e887eb93-f503-4b65-95b6-2f282c014192}")) { EntityManager.AddComponent(mob); } _stationJobs.TryAssignJob(station, jobPrototype, player.UserId); if (lateJoin) { _adminLogger.Add(LogType.LateJoin, LogImpact.Medium, $"Player {player.Name} late joined as {character.Name:characterName} on station {Name(station):stationName} with {ToPrettyString(mob):entity} as a {jobName:jobName}."); } else { _adminLogger.Add(LogType.RoundStartJoin, LogImpact.Medium, $"Player {player.Name} joined as {character.Name:characterName} on station {Name(station):stationName} with {ToPrettyString(mob):entity} as a {jobName:jobName}."); } // Make sure they're aware of extended access. if (Comp(station).ExtendedAccess && (jobPrototype.ExtendedAccess.Count > 0 || jobPrototype.ExtendedAccessGroups.Count > 0)) { _chatManager.DispatchServerMessage(player, Loc.GetString("job-greet-crew-shortages")); } if (!silent && TryComp(station, out MetaDataComponent? metaData)) { _chatManager.DispatchServerMessage(player, Loc.GetString("job-greet-station-name", ("stationName", metaData.EntityName))); } // We raise this event directed to the mob, but also broadcast it so game rules can do something now. PlayersJoinedRoundNormally++; var aev = new PlayerSpawnCompleteEvent(mob, player, jobId, lateJoin, silent, PlayersJoinedRoundNormally, station, character); RaiseLocalEvent(mob, aev, true); } public void Respawn(ICommonSession player) { _mind.WipeMind(player); _adminLogger.Add(LogType.Respawn, LogImpact.Medium, $"Player {player} was respawned."); if (LobbyEnabled) PlayerJoinLobby(player); else SpawnPlayer(player, EntityUid.Invalid); } /// /// Makes a player join into the game and spawn on a station. /// /// The player joining /// The station they're spawning on /// An optional job for them to spawn as /// Whether or not the player should be greeted upon joining public void MakeJoinGame(ICommonSession player, EntityUid station, string? jobId = null, bool silent = true) { if (!_playerGameStatuses.ContainsKey(player.UserId)) return; if (!_userDb.IsLoadComplete(player)) return; SpawnPlayer(player, station, jobId, silent: silent); } /// /// Causes the given player to join the current game as observer ghost. See also /// public void JoinAsObserver(ICommonSession player) { // Can't spawn players with a dummy ticker! if (DummyTicker) return; PlayerJoinGame(player); SpawnObserver(player); } /// /// Spawns an observer ghost and attaches the given player to it. If the player does not yet have a mind, the /// player is given a new mind with the observer role. Otherwise, the current mind is transferred to the ghost. /// public void SpawnObserver(ICommonSession player) { if (DummyTicker) return; var makeObserver = false; Entity? mind = player.GetMind(); if (mind == null) { var name = GetPlayerProfile(player).Name; var (mindId, mindComp) = _mind.CreateMind(player.UserId, name); mind = (mindId, mindComp); _mind.SetUserId(mind.Value, player.UserId); makeObserver = true; } var ghost = _ghost.SpawnGhost(mind.Value); if (makeObserver) _roles.MindAddRole(mind.Value, "MindRoleObserver"); _adminLogger.Add(LogType.LateJoin, LogImpact.Low, $"{player.Name} late joined the round as an Observer with {ToPrettyString(ghost):entity}."); } #region Spawn Points public EntityCoordinates GetObserverSpawnPoint() { _possiblePositions.Clear(); var spawnPointQuery = EntityManager.EntityQueryEnumerator(); while (spawnPointQuery.MoveNext(out var uid, out var point, out var transform)) { if (point.SpawnType != SpawnPointType.Observer || TerminatingOrDeleted(uid) || transform.MapUid == null || TerminatingOrDeleted(transform.MapUid.Value)) { continue; } _possiblePositions.Add(transform.Coordinates); } var metaQuery = GetEntityQuery(); // Fallback to a random grid. if (_possiblePositions.Count == 0) { var query = AllEntityQuery(); while (query.MoveNext(out var uid, out var grid)) { if (!metaQuery.TryGetComponent(uid, out var meta) || meta.EntityPaused || TerminatingOrDeleted(uid)) { continue; } _possiblePositions.Add(new EntityCoordinates(uid, Vector2.Zero)); } } if (_possiblePositions.Count != 0) { // TODO: This is just here for the eye lerping. // Ideally engine would just spawn them on grid directly I guess? Right now grid traversal is handling it during // update which means we need to add a hack somewhere around it. var spawn = _robustRandom.Pick(_possiblePositions); var toMap = _transform.ToMapCoordinates(spawn); if (_mapManager.TryFindGridAt(toMap, out var gridUid, out _)) { var gridXform = Transform(gridUid); return new EntityCoordinates(gridUid, Vector2.Transform(toMap.Position, _transform.GetInvWorldMatrix(gridXform))); } return spawn; } if (_mapManager.MapExists(DefaultMap)) { var mapUid = _mapManager.GetMapEntityId(DefaultMap); if (!TerminatingOrDeleted(mapUid)) return new EntityCoordinates(mapUid, Vector2.Zero); } // Just pick a point at this point I guess. foreach (var map in _mapManager.GetAllMapIds()) { var mapUid = _mapManager.GetMapEntityId(map); if (!metaQuery.TryGetComponent(mapUid, out var meta) || meta.EntityPaused || TerminatingOrDeleted(mapUid)) { continue; } return new EntityCoordinates(mapUid, Vector2.Zero); } // AAAAAAAAAAAAA // This should be an error, if it didn't cause tests to start erroring when they delete a player. _sawmill.Warning("Found no observer spawn points!"); return EntityCoordinates.Invalid; } #endregion } }