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
}