using System.Linq; using Content.Server.Chat.Managers; using Content.Server.GameTicking; using Content.Server.GameTicking.Rules; using Content.Server.Humanoid.Systems; using Content.Server.Mind.Components; using Content.Server.NPC.Systems; using Content.Server.Preferences.Managers; using Content.Server.Shuttles.Components; using Content.Server.Shuttles.Systems; using Content.Server.Spawners.Components; using Content.Server.Station.Systems; using Content.Server.Traitor; using Content.Shared.Dataset; using Content.Shared.MobState; using Content.Shared.MobState.Components; using Content.Shared.Preferences; using Content.Shared.Roles; using Robust.Server.GameObjects; using Robust.Server.Maps; using Robust.Server.Player; using Robust.Shared.Map; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Utility; namespace Content.Server.Corvax.AssaultOps; public sealed class AssaultopsRuleSystem : GameRuleSystem { [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IChatManager _chatManager = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly IServerPreferencesManager _prefs = default!; [Dependency] private readonly IMapManager _mapManager = default!; [Dependency] private readonly StationSystem _stationSystem = default!; [Dependency] private readonly SharedAudioSystem _audioSystem = default!; [Dependency] private readonly RandomHumanoidSystem _randomHumanoid = default!; [Dependency] private readonly StationSpawningSystem _stationSpawningSystem = default!; [Dependency] private readonly ShuttleSystem _shuttleSystem = default!; [Dependency] private readonly FactionSystem _faction = default!; [Dependency] private readonly MapLoaderSystem _mapLoader = default!; private enum WinType { /// /// Operative major win. Goldeneye activated and all ops alive. /// OpsMajor, /// /// Minor win. Goldeneye was activated and some ops alive. /// OpsMinor, /// /// Hearty. Goldeneye activated but no ops alive. /// Hearty, /// /// Stalemate. Goldeneye not activated and ops still alive. /// Stalemate, /// /// Crew major win. Goldeneye not activated and no ops alive. /// CrewMajor } private enum WinCondition { IcarusActivated, AllOpsDead, SomeOpsAlive, AllOpsAlive } private WinType _winType = WinType.Stalemate; private readonly List _winConditions = new(); private MapId? _outpostMap; // TODO: use components, don't just cache entity UIDs // There have been (and probably still are) bugs where these refer to deleted entities from old rounds. private EntityUid? _outpostGrid; private EntityUid? _shuttleGrid; private EntityUid? _targetStation; public override string Prototype => "Assaultops"; private AssaultopsRuleConfiguration _ruleConfig = new(); /// /// Cached operator name prototypes. /// private readonly List _operativeNames = new(); /// /// Players who played as an operative at some point in the round. /// Stores the session as well as the entity name /// private readonly Dictionary _operativePlayers = new(); public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnStartAttempt); SubscribeLocalEvent(OnPlayersSpawning); SubscribeLocalEvent(OnRunLevelChanged); SubscribeLocalEvent(OnRoundEndText); SubscribeLocalEvent(OnComponentInit); } private void OnStartAttempt(RoundStartAttemptEvent ev) { if (!RuleAdded || Configuration is not AssaultopsRuleConfiguration assaultOpsConfig) return; _ruleConfig = assaultOpsConfig; var minPlayers = assaultOpsConfig.MinPlayers; if (!ev.Forced && ev.Players.Length < minPlayers) { _chatManager.DispatchServerAnnouncement(Loc.GetString("assaultops-not-enough-ready-players", ("readyPlayersCount", ev.Players.Length), ("minimumPlayers", minPlayers))); ev.Cancel(); return; } if (ev.Players.Length != 0) return; _chatManager.DispatchServerAnnouncement(Loc.GetString("assaultops-no-one-ready")); ev.Cancel(); } private void OnPlayersSpawning(RulePlayerSpawningEvent ev) { if (!RuleAdded) return; // Basically copied verbatim from traitor code var playersPerOperative = _ruleConfig.PlayersPerOperative; var maxOperatives = _ruleConfig.MaxOperatives; var numOps = MathHelper.Clamp(ev.PlayerPool.Count / playersPerOperative, 1, maxOperatives); var opsPool = FindPotentialOperatives(ev); var selectedOps = PickOperatives(numOps, opsPool); SpawnOperatives(selectedOps); foreach (var session in selectedOps) { ev.PlayerPool.Remove(session); GameTicker.PlayerJoinGame(session); var name = session.AttachedEntity == null ? string.Empty : MetaData(session.AttachedEntity.Value).EntityName; // TODO: Fix this being able to have duplicates _operativePlayers[name] = session; } } private List FindPotentialOperatives(RulePlayerSpawningEvent ev) { var eligible = new List(ev.PlayerPool).Where(p => ev.Profiles.TryGetValue(p.UserId, out var profile) && profile.AntagPreferences.Contains(_ruleConfig.OperativeRoleProto) ).ToList(); if (eligible.Count == 0) { Logger.InfoS("preset", "Insufficient preferred assaultops, create pool from everyone."); return ev.PlayerPool; } return eligible; } private List PickOperatives(int count, List pool) { var selected = new List(count); if (pool.Count == 0) { Logger.InfoS("preset", "Insufficient ready players to fill up with assaultops, stopping the selection"); return selected; } for (var i = 0; i < count; i++) { selected.Add(_random.PickAndTake(pool)); Logger.InfoS("preset", "Selected a assaultop."); } return selected; } private void SpawnOperatives(List operatives) { if (_outpostGrid == null) return; var spawnpoints = GetSpawnpoints(_outpostGrid.Value); foreach (var session in operatives) { var spawnpoint = _random.Pick(spawnpoints); SpawnOperative(session, spawnpoint); } } private void SpawnOperative(IPlayerSession session, EntityCoordinates spawnpoint) { var name = Loc.GetString("nukeops-role-operator") + " " + _random.PickAndTake(_operativeNames); var mob = _randomHumanoid.SpawnRandomHumanoid(_ruleConfig.RandomHumanoidSettingsPrototype, spawnpoint, name); var profile = _prefs.GetPreferences(session.UserId).SelectedCharacter as HumanoidCharacterProfile; // EntityManager.EnsureComponent(mob); EnsureComp(mob); var gearProto = _prototypeManager.Index(_ruleConfig.OperativeStartGearPrototype); _stationSpawningSystem.EquipStartingGear(mob, gearProto, profile); _faction.RemoveFaction(mob, "NanoTrasen", false); _faction.AddFaction(mob, "Syndicate"); var newMind = new Mind.Mind(session.UserId) { CharacterName = name }; newMind.ChangeOwningPlayer(session.UserId); var antagProto = _prototypeManager.Index(_ruleConfig.OperativeRoleProto); newMind.AddRole(new TraitorRole(newMind, antagProto)); newMind.TransferTo(mob); } private List GetSpawnpoints(EntityUid outpostUid) { var spawns = new List(); // Forgive sloth for hardcoding prototypes foreach (var (_, meta, xform) in EntityManager.EntityQuery(true)) { if (meta.EntityPrototype?.ID != _ruleConfig.SpawnPointPrototype) continue; if (xform.ParentUid != _outpostGrid) continue; spawns.Add(xform.Coordinates); break; } if (spawns.Count == 0) { spawns.Add(EntityManager.GetComponent(outpostUid).Coordinates); Logger.WarningS("assaultops", $"Fell back to default spawn for assaultops!"); } return spawns; } private void OnComponentInit(EntityUid uid, AssaultOperativeComponent component, ComponentInit args) { // If entity has a prior mind attached, add them to the players list. if (!TryComp(uid, out var mindComponent) || !RuleAdded) return; var session = mindComponent.Mind?.Session; var name = MetaData(uid).EntityName; if (session != null) _operativePlayers.Add(name, session); } private void OnRunLevelChanged(GameRunLevelChangedEvent ev) { switch (ev.New) { case GameRunLevel.InRound: OnRoundStart(); break; case GameRunLevel.PostRound: OnRoundEnd(); break; } } private void OnRoundStart() { // TODO: This needs to try and target a Nanotrasen station. At the very least, // we can only currently guarantee that NT stations are the only station to // exist in the base game. _targetStation = _stationSystem.Stations.FirstOrNull(); if (_targetStation == null) return; var filter = Filter.Empty(); foreach (var op in EntityQuery()) { if (!TryComp(op.Owner, out var actor)) continue; _chatManager.DispatchServerMessage(actor.PlayerSession, Loc.GetString("assaultops-welcome", ("station", _targetStation.Value))); filter.AddPlayer(actor.PlayerSession); } _audioSystem.PlayGlobal(_ruleConfig.GreetSound, filter, recordReplay: false); } private void OnRoundEnd() { var total = 0; var alive = 0; foreach (var (_, state) in EntityQuery()) { total++; if (state.CurrentState is DamageState.Alive) continue; alive++; break; } var allAlive = alive == total; if (allAlive) { _winType = WinType.OpsMinor; _winConditions.Add(WinCondition.AllOpsAlive); } else if (alive == 0) { _winConditions.Add(WinCondition.AllOpsDead); } else { _winConditions.Add(WinCondition.SomeOpsAlive); } } private void OnRoundEndText(RoundEndTextAppendEvent ev) { if (!RuleAdded) return; ev.AddLine(Loc.GetString($"assaultops-{_winType.ToString().ToLower()}")); foreach (var cond in _winConditions) ev.AddLine(Loc.GetString($"assaultops-cond-{cond.ToString().ToLower()}")); ev.AddLine(Loc.GetString("assaultops-list-start")); foreach (var (name, session) in _operativePlayers) { var listing = Loc.GetString("assaultops-list-name", ("name", name), ("user", session.Name)); ev.AddLine(listing); } } public override void Started() { _winType = WinType.Stalemate; _winConditions.Clear(); _outpostGrid = null; _operativePlayers.Clear(); _operativeNames.Clear(); _operativeNames.AddRange(_prototypeManager.Index(_ruleConfig.OperativeNames).Values); if (!SpawnMap()) { Logger.InfoS("assaultops", "Failed to load map for assaultops"); return; } LoadExistOperatives(); } private bool SpawnMap() { if (_outpostMap != null) return true; // Map is already loaded var outpostMap = _ruleConfig.OutpostMap; if (outpostMap == null) { Logger.ErrorS("assaultops", "No station map specified for assaultops!"); return false; } var shuttlePath = _ruleConfig.ShuttleMap; if (shuttlePath == null) { Logger.ErrorS("assaultops", "No shuttle map specified for assaultops!"); return false; } var mapId = _mapManager.CreateMap(); var options = new MapLoadOptions() { LoadMap = true }; if (!_mapLoader.TryLoad(mapId, outpostMap.ToString(), out var outpostGrids, options) || outpostGrids.Count == 0) { Logger.ErrorS("assaultops", $"Error loading map {outpostMap} for assaultops!"); return false; } // Assume the first grid is the outpost grid. _outpostGrid = outpostGrids[0]; // Listen I just don't want it to overlap. if (!_mapLoader.TryLoad(mapId, shuttlePath.ToString(), out var grids, new MapLoadOptions {Offset = Vector2.One*1000f}) || !grids.Any()) { Logger.ErrorS("assaultops", $"Error loading grid {shuttlePath} for assaultops!"); return false; } var shuttleId = grids.First(); // Naughty, someone saved the shuttle as a map. if (Deleted(shuttleId)) { Logger.ErrorS("assaultops", $"Tried to load assaultops shuttle as a map, aborting."); _mapManager.DeleteMap(mapId); return false; } if (TryComp(shuttleId, out var shuttle)) { _shuttleSystem.TryFTLDock(shuttle, _outpostGrid.Value); } _outpostMap = mapId; _shuttleGrid = shuttleId; return true; } private void LoadExistOperatives() { // Add pre-existing nuke operatives to the credit list. var query = EntityQuery(true); foreach (var (_, mindComp) in query) { if (mindComp.Mind == null || !mindComp.Mind.TryGetSession(out var session)) continue; var name = MetaData(mindComp.Owner).EntityName; _operativePlayers.Add(name, session); } } public override void Ended() { } }