using System.Diagnostics.CodeAnalysis; using System.Linq; using Content.Server.GameTicking; using Content.Server.Station.Components; using Content.Shared.CCVar; using Content.Shared.FixedPoint; using Content.Shared.GameTicking; using Content.Shared.Preferences; using Content.Shared.Roles; using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; namespace Content.Server.Station.Systems; /// /// Manages job slots for stations. /// [PublicAPI] public sealed partial class StationJobsSystem : EntitySystem { [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly IPlayerManager _player = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly GameTicker _gameTicker = default!; /// public override void Initialize() { SubscribeLocalEvent(OnStationInitialized); SubscribeLocalEvent(OnInit); SubscribeLocalEvent(OnStationRenamed); SubscribeLocalEvent(OnStationDeletion); SubscribeLocalEvent(OnPlayerJoinedLobby); Subs.CVar(_configurationManager, CCVars.GameDisallowLateJoins, _ => UpdateJobsAvailable(), true); } private void OnInit(Entity ent, ref ComponentInit args) { ent.Comp.MidRoundTotalJobs = ent.Comp.SetupAvailableJobs.Values .Select(x => Math.Max(x[1], 0)) .Sum(); ent.Comp.OverflowJobs = ent.Comp.SetupAvailableJobs .Where(x => x.Value[0] < 0) .Select(x => x.Key) .ToHashSet(); } public override void Update(float _) { if (_availableJobsDirty) { _cachedAvailableJobs = GenerateJobsAvailableEvent(); RaiseNetworkEvent(_cachedAvailableJobs, Filter.Empty().AddPlayers(_player.Sessions)); _availableJobsDirty = false; } } private void OnStationDeletion(EntityUid uid, StationJobsComponent component, ComponentShutdown args) { UpdateJobsAvailable(); // we no longer exist so the jobs list is changed. } private void OnStationInitialized(StationInitializedEvent msg) { if (!TryComp(msg.Station, out var stationJobs)) return; stationJobs.JobList = stationJobs.SetupAvailableJobs.ToDictionary( x => x.Key, x=> (int?)(x.Value[1] < 0 ? null : x.Value[1])); stationJobs.TotalJobs = stationJobs.JobList.Values.Select(x => x ?? 0).Sum(); UpdateJobsAvailable(); } #region Public API /// /// Station to assign a job on. /// Job to assign. /// The net user ID of the player we're assigning this job to. /// Resolve pattern, station jobs component of the station. public bool TryAssignJob(EntityUid station, JobPrototype job, NetUserId netUserId, StationJobsComponent? stationJobs = null) { return TryAssignJob(station, job.ID, netUserId, stationJobs); } /// /// Attempts to assign the given job once. (essentially, it decrements the slot if possible). /// /// Station to assign a job on. /// Job prototype ID to assign. /// The net user ID of the player we're assigning this job to. /// Resolve pattern, station jobs component of the station. /// Whether or not assignment was a success. /// Thrown when the given station is not a station. public bool TryAssignJob(EntityUid station, string jobPrototypeId, NetUserId netUserId, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs, false)) return false; if (!TryAdjustJobSlot(station, jobPrototypeId, -1, false, false, stationJobs)) return false; stationJobs.PlayerJobs.TryAdd(netUserId, new()); stationJobs.PlayerJobs[netUserId].Add(jobPrototypeId); return true; } /// /// Station to adjust the job slot on. /// Job to adjust. /// Amount to adjust by. /// Whether or not it should create the slot if it doesn't exist. /// Whether or not to clamp to zero if you'd remove more jobs than are available. /// Resolve pattern, station jobs component of the station. public bool TryAdjustJobSlot(EntityUid station, JobPrototype job, int amount, bool createSlot = false, bool clamp = false, StationJobsComponent? stationJobs = null) { return TryAdjustJobSlot(station, job.ID, amount, createSlot, clamp, stationJobs); } /// /// Attempts to adjust the given job slot by the amount provided. /// /// Station to adjust the job slot on. /// Job prototype ID to adjust. /// Amount to adjust by. /// Whether or not it should create the slot if it doesn't exist. /// Whether or not to clamp to zero if you'd remove more jobs than are available. /// Resolve pattern, station jobs component of the station. /// Whether or not slot adjustment was a success. /// Thrown when the given station is not a station. public bool TryAdjustJobSlot(EntityUid station, string jobPrototypeId, int amount, bool createSlot = false, bool clamp = false, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); var jobList = stationJobs.JobList; // This should: // - Return true when zero slots are added/removed. // - Return true when you add. // - Return true when you remove and do not exceed the number of slot available. // - Return false when you remove from a job that doesn't exist. // - Return false when you remove and exceed the number of slots available. // And additionally, if adding would add a job not previously on the manifest when createSlot is false, return false and do nothing. if (amount == 0) return true; switch (jobList.TryGetValue(jobPrototypeId, out var available)) { case false when amount < 0: return false; case false: if (!createSlot) return false; stationJobs.TotalJobs += amount; jobList[jobPrototypeId] = amount; UpdateJobsAvailable(); return true; case true: // Job is unlimited so just say we adjusted it and do nothing. if (available is not {} avail) return true; // Would remove more jobs than we have available. if (available + amount < 0 && !clamp) return false; jobList[jobPrototypeId] = Math.Max(avail + amount, 0); stationJobs.TotalJobs = jobList.Values.Select(x => x ?? 0).Sum(); UpdateJobsAvailable(); return true; } } public bool TryGetPlayerJobs(EntityUid station, NetUserId userId, [NotNullWhen(true)] out List>? jobs, StationJobsComponent? jobsComponent = null) { jobs = null; if (!Resolve(station, ref jobsComponent, false)) return false; return jobsComponent.PlayerJobs.TryGetValue(userId, out jobs); } public bool TryRemovePlayerJobs(EntityUid station, NetUserId userId, StationJobsComponent? jobsComponent = null) { if (!Resolve(station, ref jobsComponent, false)) return false; return jobsComponent.PlayerJobs.Remove(userId); } /// /// Station to adjust the job slot on. /// Job prototype to adjust. /// Amount to set to. /// Whether or not it should create the slot if it doesn't exist. /// Resolve pattern, station jobs component of the station. /// public bool TrySetJobSlot(EntityUid station, JobPrototype jobPrototype, int amount, bool createSlot = false, StationJobsComponent? stationJobs = null) { return TrySetJobSlot(station, jobPrototype.ID, amount, createSlot, stationJobs); } /// /// Attempts to set the given job slot to the amount provided. /// /// Station to adjust the job slot on. /// Job prototype ID to adjust. /// Amount to set to. /// Whether or not it should create the slot if it doesn't exist. /// Resolve pattern, station jobs component of the station. /// Whether or not setting the value succeeded. /// Thrown when the given station is not a station. public bool TrySetJobSlot(EntityUid station, string jobPrototypeId, int amount, bool createSlot = false, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); if (amount < 0) throw new ArgumentException("Tried to set a job to have a negative number of slots!", nameof(amount)); var jobList = stationJobs.JobList; switch (jobList.ContainsKey(jobPrototypeId)) { case false: if (!createSlot) return false; stationJobs.TotalJobs += amount; jobList[jobPrototypeId] = amount; UpdateJobsAvailable(); return true; case true: stationJobs.TotalJobs += amount - (jobList[jobPrototypeId] ?? 0); jobList[jobPrototypeId] = amount; UpdateJobsAvailable(); return true; } } /// /// Station to make a job unlimited on. /// Job to make unlimited. /// Resolve pattern, station jobs component of the station. public void MakeJobUnlimited(EntityUid station, JobPrototype job, StationJobsComponent? stationJobs = null) { MakeJobUnlimited(station, job.ID, stationJobs); } /// /// Makes the given job have unlimited slots. /// /// Station to make a job unlimited on. /// Job prototype ID to make unlimited. /// Resolve pattern, station jobs component of the station. /// Thrown when the given station is not a station. public void MakeJobUnlimited(EntityUid station, string jobPrototypeId, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); // Subtract out the job we're fixing to make have unlimited slots. if (stationJobs.JobList.TryGetValue(jobPrototypeId, out var existing)) stationJobs.TotalJobs -= existing ?? 0; stationJobs.JobList[jobPrototypeId] = null; UpdateJobsAvailable(); } /// /// Station to check. /// Job to check. /// Resolve pattern, station jobs component of the station. public bool IsJobUnlimited(EntityUid station, JobPrototype job, StationJobsComponent? stationJobs = null) { return IsJobUnlimited(station, job.ID, stationJobs); } /// /// Checks if the given job is unlimited. /// /// Station to check. /// Job prototype ID to check. /// Resolve pattern, station jobs component of the station. /// Returns if the given slot is unlimited. /// Thrown when the given station is not a station. public bool IsJobUnlimited(EntityUid station, string jobPrototypeId, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); return stationJobs.JobList.TryGetValue(jobPrototypeId, out var job) && job == null; } /// /// Station to get slot info from. /// Job to get slot info for. /// The number of slots remaining. Null if infinite. /// Resolve pattern, station jobs component of the station. public bool TryGetJobSlot(EntityUid station, JobPrototype job, out int? slots, StationJobsComponent? stationJobs = null) { return TryGetJobSlot(station, job.ID, out slots, stationJobs); } /// /// Returns information about the given job slot. /// /// Station to get slot info from. /// Job prototype ID to get slot info for. /// The number of slots remaining. Null if infinite. /// Resolve pattern, station jobs component of the station. /// Whether or not the slot exists. /// Thrown when the given station is not a station. /// slots will be null if the slot doesn't exist, as well, so make sure to check the return value. public bool TryGetJobSlot(EntityUid station, string jobPrototypeId, out int? slots, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); return stationJobs.JobList.TryGetValue(jobPrototypeId, out slots); } /// /// Returns all jobs available on the station. /// /// Station to get jobs for /// Resolve pattern, station jobs component of the station. /// Set containing all jobs available. /// Thrown when the given station is not a station. public IEnumerable> GetAvailableJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); return stationJobs.JobList .Where(x => x.Value != 0) .Select(x => x.Key); } /// /// Returns all overflow jobs available on the station. /// /// Station to get jobs for /// Resolve pattern, station jobs component of the station. /// Set containing all overflow jobs available. /// Thrown when the given station is not a station. public IReadOnlySet> GetOverflowJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); return stationJobs.OverflowJobs; } /// /// Returns a readonly dictionary of all jobs and their slot info. /// /// Station to get jobs for /// Resolve pattern, station jobs component of the station. /// List of all jobs on the station. /// Thrown when the given station is not a station. public IReadOnlyDictionary, int?> GetJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); return stationJobs.JobList; } /// /// Returns a readonly dictionary of all round-start jobs and their slot info. /// /// Station to get jobs for /// Resolve pattern, station jobs component of the station. /// List of all round-start jobs. /// Thrown when the given station is not a station. public Dictionary, int?> GetRoundStartJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); return stationJobs.SetupAvailableJobs.ToDictionary( x => x.Key, x=> (int?)(x.Value[0] < 0 ? null : x.Value[0])); } /// /// Looks at the given priority list, and picks the best available job (optionally with the given exclusions) /// /// Station to pick from. /// The priority list to use for selecting a job. /// Whether or not to pick from the overflow list. /// A set of disallowed jobs, if any. /// The selected job, if any. public ProtoId? PickBestAvailableJobWithPriority(EntityUid station, IReadOnlyDictionary, JobPriority> jobPriorities, bool pickOverflows, IReadOnlySet>? disallowedJobs = null) { if (station == EntityUid.Invalid) return null; var available = GetAvailableJobs(station); bool TryPick(JobPriority priority, [NotNullWhen(true)] out ProtoId? jobId) { var filtered = jobPriorities .Where(p => p.Value == priority && disallowedJobs != null && !disallowedJobs.Contains(p.Key) && available.Contains(p.Key)) .Select(p => p.Key) .ToList(); if (filtered.Count != 0) { jobId = _random.Pick(filtered); return true; } jobId = default; return false; } if (TryPick(JobPriority.High, out var picked)) { return picked; } if (TryPick(JobPriority.Medium, out picked)) { return picked; } if (TryPick(JobPriority.Low, out picked)) { return picked; } if (!pickOverflows) return null; var overflows = GetOverflowJobs(station); if (overflows.Count == 0) return null; return _random.Pick(overflows); } #endregion Public API #region Latejoin job management private bool _availableJobsDirty; private TickerJobsAvailableEvent _cachedAvailableJobs = new(new(), new()); /// /// Assembles an event from the current available-to-play jobs. /// This is moderately expensive to construct. /// /// The event. private TickerJobsAvailableEvent GenerateJobsAvailableEvent() { // If late join is disallowed, return no available jobs. if (_gameTicker.DisallowLateJoin) return new TickerJobsAvailableEvent(new(), new()); var jobs = new Dictionary, int?>>(); var stationNames = new Dictionary(); var query = EntityQueryEnumerator(); while (query.MoveNext(out var station, out var comp)) { var netStation = GetNetEntity(station); var list = comp.JobList.ToDictionary(x => x.Key, x => x.Value); jobs.Add(netStation, list); stationNames.Add(netStation, Name(station)); } return new TickerJobsAvailableEvent(stationNames, jobs); } /// /// Updates the cached available jobs. Moderately expensive. /// private void UpdateJobsAvailable() { _availableJobsDirty = true; } private void OnPlayerJoinedLobby(PlayerJoinedLobbyEvent ev) { RaiseNetworkEvent(_cachedAvailableJobs, ev.PlayerSession.Channel); } private void OnStationRenamed(EntityUid uid, StationJobsComponent component, StationRenamedEvent args) { UpdateJobsAvailable(); } #endregion }