StationSystem/jobs/partial spawning refactor (#7580)
* Partial work on StationSystem refactor. * WIP station jobs API. * forgor to fire off grid events. * Partial implementation of StationSpawningSystem * whoops infinite loop. * Spawners should work now. * it compiles. * tfw * Vestigial code cleanup. * fix station deletion. * attempt to make tests go brr * add latejoin spawnpoints to test maps. * make sure the station still exists while destructing spawners. * forgot an exists check. * destruction order check. * hopefully fix final test. * fail-safe radstorm. * Deep-clean job code further. This is bugged!!!!! * Fix job bug. (init order moment) * whooo cleanup * New job selection algorithm that tries to distribute fairly across stations. * small nitpicks * Give the heads their weights to replace the head field. * make overflow assign take a station list. * moment * Fixes and test #1 of many. * please fix nullspace * AssignJobs should no longer even consider showing up on a trace. * add comment. * Introduce station configs, praying i didn't miss something. * in one small change stations are now fully serializable. * Further doc comments. * whoops. * Solve bug where assignjobs didn't account for roundstart. * Fix spawning, improve the API. Caught an oversight in stationsystem that should've broke everything but didn't, whoops. * Goodbye JobController. * minor fix.. * fix test fail, remove debug logs. * quick serialization fixes. * fixes.. * sus * partialing * Update Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs Co-authored-by: Kara <lunarautomaton6@gmail.com> * Use dirtying to avoid rebuilding the list 2,100 times. * add a bajillion more lines of docs (mostly in AssignJobs so i don't ever forget how it works) * Update Content.IntegrationTests/Tests/Station/StationJobsTest.cs Co-authored-by: Kara <lunarautomaton6@gmail.com> * Add the Mysteriously Missing Captain Check. * Put maprender back the way it belongs. * I love addressing reviews. * Update Content.Server/Station/Systems/StationJobsSystem.cs Co-authored-by: Kara <lunarautomaton6@gmail.com> * doc cleanup. * Fix bureaucratic error, add job slot tests. * zero cost abstractions when * cri * saner error. * Fix spawning failing certain tests due to gameticker not handling falliability correctly. Can't fix this until I refactor the rest of spawning code. * submodule gaming * Packedenger. * Documentation consistency. Co-authored-by: Kara <lunarautomaton6@gmail.com>
This commit is contained in:
347
Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs
Normal file
347
Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs
Normal file
@@ -0,0 +1,347 @@
|
||||
using System.Linq;
|
||||
using Content.Server.Administration.Managers;
|
||||
using Content.Shared.Preferences;
|
||||
using Content.Shared.Roles;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.Station.Systems;
|
||||
|
||||
// Contains code for round-start spawning.
|
||||
public sealed partial class StationJobsSystem
|
||||
{
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
[Dependency] private readonly RoleBanManager _roleBanManager = default!;
|
||||
|
||||
private Dictionary<int, HashSet<string>> _jobsByWeight = default!;
|
||||
private List<int> _orderedWeights = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up some tables used by AssignJobs, including jobs sorted by their weights, and a list of weights in order from highest to lowest.
|
||||
/// </summary>
|
||||
private void InitializeRoundStart()
|
||||
{
|
||||
_jobsByWeight = new Dictionary<int, HashSet<string>>();
|
||||
foreach (var job in _prototypeManager.EnumeratePrototypes<JobPrototype>())
|
||||
{
|
||||
if (!_jobsByWeight.ContainsKey(job.Weight))
|
||||
_jobsByWeight.Add(job.Weight, new HashSet<string>());
|
||||
|
||||
_jobsByWeight[job.Weight].Add(job.ID);
|
||||
}
|
||||
|
||||
_orderedWeights = _jobsByWeight.Keys.OrderByDescending(i => i).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns jobs based on the given preferences and list of stations to assign for.
|
||||
/// This does NOT change the slots on the station, only figures out where each player should go.
|
||||
/// </summary>
|
||||
/// <param name="profiles">The profiles to use for selection.</param>
|
||||
/// <param name="stations">List of stations to assign for.</param>
|
||||
/// <param name="useRoundStartJobs">Whether or not to use the round-start jobs for the stations instead of their current jobs.</param>
|
||||
/// <returns>List of players and their assigned jobs.</returns>
|
||||
/// <remarks>
|
||||
/// You probably shouldn't use useRoundStartJobs mid-round if the station has been available to join,
|
||||
/// as there may end up being more round-start slots than available slots, which can cause weird behavior.
|
||||
/// A warning to all who enter ye cursed lands: This function is long and mildly incomprehensible. Best used without touching.
|
||||
/// </remarks>
|
||||
public Dictionary<NetUserId, (string, EntityUid)> AssignJobs(Dictionary<NetUserId, HumanoidCharacterProfile> profiles, IReadOnlyList<EntityUid> stations, bool useRoundStartJobs = true)
|
||||
{
|
||||
DebugTools.Assert(stations.Count > 0);
|
||||
|
||||
if (profiles.Count == 0)
|
||||
return new Dictionary<NetUserId, (string, EntityUid)>();
|
||||
|
||||
// We need to modify this collection later, so make a copy of it.
|
||||
profiles = profiles.ShallowClone();
|
||||
|
||||
// Player <-> (job, station)
|
||||
var assigned = new Dictionary<NetUserId, (string, EntityUid)>(profiles.Count);
|
||||
|
||||
// The jobs left on the stations. This collection is modified as jobs are assigned to track what's available.
|
||||
var stationJobs = new Dictionary<EntityUid, Dictionary<string, uint?>>();
|
||||
foreach (var station in stations)
|
||||
{
|
||||
if (useRoundStartJobs)
|
||||
{
|
||||
stationJobs.Add(station, GetRoundStartJobs(station).ToDictionary(x => x.Key, x => x.Value));
|
||||
}
|
||||
else
|
||||
{
|
||||
stationJobs.Add(station, GetJobs(station).ToDictionary(x => x.Key, x => x.Value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// We reuse this collection. It tracks what jobs we're currently trying to select players for.
|
||||
var currentlySelectingJobs = new Dictionary<EntityUid, Dictionary<string, uint?>>(stations.Count);
|
||||
foreach (var station in stations)
|
||||
{
|
||||
currentlySelectingJobs.Add(station, new Dictionary<string, uint?>());
|
||||
}
|
||||
|
||||
// And these.
|
||||
// Tracks what players are available for a given job in the current iteration of selection.
|
||||
var jobPlayerOptions = new Dictionary<string, HashSet<NetUserId>>();
|
||||
// Tracks the total number of slots for the given stations in the current iteration of selection.
|
||||
var stationTotalSlots = new Dictionary<EntityUid, int>(stations.Count);
|
||||
// The share of the players each station gets in the current iteration of job selection.
|
||||
var stationShares = new Dictionary<EntityUid, int>(stations.Count);
|
||||
|
||||
// Ok so the general algorithm:
|
||||
// We start with the highest weight jobs and work our way down. We filter jobs by weight when selecting as well.
|
||||
// Weight > Priority > Station.
|
||||
foreach (var weight in _orderedWeights)
|
||||
{
|
||||
for (var selectedPriority = JobPriority.High; selectedPriority > JobPriority.Never; selectedPriority--)
|
||||
{
|
||||
if (profiles.Count == 0)
|
||||
goto endFunc;
|
||||
|
||||
var candidates = GetPlayersJobCandidates(weight, selectedPriority, profiles);
|
||||
|
||||
var optionsRemaining = 0;
|
||||
|
||||
// Assigns a player to the given station, updating all the bookkeeping while at it.
|
||||
void AssignPlayer(NetUserId player, string job, EntityUid station)
|
||||
{
|
||||
// Remove the player from all possible jobs as that's faster than actually checking what they have selected.
|
||||
foreach (var (_, players) in jobPlayerOptions)
|
||||
{
|
||||
players.Remove(player);
|
||||
}
|
||||
|
||||
stationJobs[station][job]--;
|
||||
profiles.Remove(player);
|
||||
assigned.Add(player, (job, station));
|
||||
|
||||
optionsRemaining--;
|
||||
}
|
||||
|
||||
jobPlayerOptions.Clear(); // We reuse this collection.
|
||||
|
||||
// Goes through every candidate, and adds them to jobPlayerOptions, so that the candidate players
|
||||
// have an index sorted by job. We use this (much) later when actually assigning people to randomly
|
||||
// pick from the list of candidates for the job.
|
||||
foreach (var (user, jobs) in candidates)
|
||||
{
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
if (!jobPlayerOptions.ContainsKey(job))
|
||||
jobPlayerOptions.Add(job, new HashSet<NetUserId>());
|
||||
|
||||
jobPlayerOptions[job].Add(user);
|
||||
}
|
||||
|
||||
optionsRemaining++;
|
||||
}
|
||||
|
||||
// We reuse this collection, so clear it's children.
|
||||
foreach (var slots in currentlySelectingJobs)
|
||||
{
|
||||
slots.Value.Clear();
|
||||
}
|
||||
|
||||
// Go through every station..
|
||||
foreach (var station in stations)
|
||||
{
|
||||
var slots = currentlySelectingJobs[station];
|
||||
|
||||
// Get all of the jobs in the selected weight category.
|
||||
foreach (var (job, slot) in stationJobs[station])
|
||||
{
|
||||
if (_jobsByWeight[weight].Contains(job))
|
||||
slots.Add(job, slot);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clear for reuse.
|
||||
stationTotalSlots.Clear();
|
||||
|
||||
// Intentionally discounts the value of uncapped slots! They're only a single slot when deciding a station's share.
|
||||
foreach (var (station, jobs) in currentlySelectingJobs)
|
||||
{
|
||||
stationTotalSlots.Add(
|
||||
station,
|
||||
(int)jobs.Values.Sum(x => x ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
var totalSlots = 0;
|
||||
|
||||
// LINQ moment.
|
||||
// totalSlots = stationTotalSlots.Sum(x => x.Value);
|
||||
foreach (var (_, slot) in stationTotalSlots)
|
||||
{
|
||||
totalSlots += slot;
|
||||
}
|
||||
|
||||
if (totalSlots == 0)
|
||||
continue; // No slots so just move to the next iteration.
|
||||
|
||||
// Clear for reuse.
|
||||
stationShares.Clear();
|
||||
|
||||
// How many players we've distributed so far. Used to grant any remaining slots if we have leftovers.
|
||||
var distributed = 0;
|
||||
|
||||
// Goes through each station and figures out how many players we should give it for the current iteration.
|
||||
foreach (var station in stations)
|
||||
{
|
||||
// Calculates the percent share then multiplies.
|
||||
stationShares[station] = (int)Math.Floor(((float)stationTotalSlots[station] / totalSlots) * candidates.Count);
|
||||
distributed += stationShares[station];
|
||||
}
|
||||
|
||||
// Avoids the fair share problem where if there's two stations and one player neither gets one.
|
||||
// We do this by simply selecting a station randomly and giving it the remaining share(s).
|
||||
if (distributed < candidates.Count)
|
||||
{
|
||||
var choice = _random.Pick(stations);
|
||||
stationShares[choice] += candidates.Count - distributed;
|
||||
}
|
||||
|
||||
// Actual meat, goes through each station and shakes the tree until everyone has a job.
|
||||
foreach (var station in stations)
|
||||
{
|
||||
if (stationShares[station] == 0)
|
||||
continue;
|
||||
|
||||
// The jobs we're selecting from for the current station.
|
||||
var currStationSelectingJobs = currentlySelectingJobs[station];
|
||||
// We only need this list because we need to go through this in a random order.
|
||||
// Oh the misery, another allocation.
|
||||
var allJobs = currStationSelectingJobs.Keys.ToList();
|
||||
_random.Shuffle(allJobs);
|
||||
// And iterates through all it's jobs in a random order until the count settles.
|
||||
// No, AFAIK it cannot be done any saner than this. I hate "shaking" collections as much
|
||||
// as you do but it's what seems to be the absolute best option here.
|
||||
// It doesn't seem to show up on the chart, perf-wise, anyway, so it's likely fine.
|
||||
int priorCount;
|
||||
do
|
||||
{
|
||||
priorCount = stationShares[station];
|
||||
|
||||
foreach (var job in allJobs)
|
||||
{
|
||||
if (stationShares[station] == 0)
|
||||
break;
|
||||
|
||||
if (currStationSelectingJobs[job] != null && currStationSelectingJobs[job] == 0)
|
||||
continue; // Can't assign this job.
|
||||
|
||||
if (!jobPlayerOptions.ContainsKey(job))
|
||||
continue;
|
||||
|
||||
// Picking players it finds that have the job set.
|
||||
var player = _random.Pick(jobPlayerOptions[job]);
|
||||
AssignPlayer(player, job, station);
|
||||
stationShares[station]--;
|
||||
|
||||
if (currStationSelectingJobs[job] != null)
|
||||
currStationSelectingJobs[job]--;
|
||||
|
||||
if (optionsRemaining == 0)
|
||||
goto done;
|
||||
}
|
||||
} while (priorCount != stationShares[station]);
|
||||
}
|
||||
done: ;
|
||||
}
|
||||
}
|
||||
|
||||
endFunc:
|
||||
return assigned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to assign overflow jobs to any player in allPlayersToAssign that is not in assignedJobs.
|
||||
/// </summary>
|
||||
/// <param name="assignedJobs">All assigned jobs.</param>
|
||||
/// <param name="allPlayersToAssign">All players that might need an overflow assigned.</param>
|
||||
/// <param name="profiles">Player character profiles.</param>
|
||||
/// <param name="stations">The stations to consider for spawn location.</param>
|
||||
public void AssignOverflowJobs(ref Dictionary<NetUserId, (string, EntityUid)> assignedJobs,
|
||||
IEnumerable<NetUserId> allPlayersToAssign, IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> profiles, IReadOnlyList<EntityUid> stations)
|
||||
{
|
||||
var givenStations = stations.ToList();
|
||||
if (givenStations.Count == 0)
|
||||
return; // Don't attempt to assign them if there are no stations.
|
||||
// For players without jobs, give them the overflow job if they have that set...
|
||||
foreach (var player in allPlayersToAssign)
|
||||
{
|
||||
if (assignedJobs.ContainsKey(player))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var profile = profiles[player];
|
||||
if (profile.PreferenceUnavailable != PreferenceUnavailableMode.SpawnAsOverflow)
|
||||
continue;
|
||||
|
||||
_random.Shuffle(givenStations);
|
||||
|
||||
foreach (var station in givenStations)
|
||||
{
|
||||
// Pick a random overflow job from that station
|
||||
var overflows = GetOverflowJobs(station).ToList();
|
||||
_random.Shuffle(overflows);
|
||||
|
||||
// Stations with no overflow slots should simply get skipped over.
|
||||
if (overflows.Count == 0)
|
||||
continue;
|
||||
|
||||
// If the overflow exists, put them in as it.
|
||||
assignedJobs.Add(player, (overflows[0], givenStations[0]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all jobs that the input players have that match the given weight and priority.
|
||||
/// </summary>
|
||||
/// <param name="weight">Weight to find, if any.</param>
|
||||
/// <param name="selectedPriority">Priority to find, if any.</param>
|
||||
/// <param name="profiles">Profiles to look in.</param>
|
||||
/// <returns>Players and a list of their matching jobs.</returns>
|
||||
private Dictionary<NetUserId, List<string>> GetPlayersJobCandidates(int? weight, JobPriority? selectedPriority, Dictionary<NetUserId, HumanoidCharacterProfile> profiles)
|
||||
{
|
||||
var outputDict = new Dictionary<NetUserId, List<string>>(profiles.Count);
|
||||
|
||||
foreach (var (player, profile) in profiles)
|
||||
{
|
||||
var roleBans = _roleBanManager.GetJobBans(player);
|
||||
|
||||
List<string>? availableJobs = null;
|
||||
|
||||
foreach (var (jobId, priority) in profile.JobPriorities)
|
||||
{
|
||||
if (!(priority == selectedPriority || selectedPriority is null))
|
||||
continue;
|
||||
|
||||
if (!_prototypeManager.TryIndex(jobId, out JobPrototype? job))
|
||||
continue;
|
||||
|
||||
if (weight is not null && job.Weight != weight.Value)
|
||||
continue;
|
||||
|
||||
if (!(roleBans == null || !roleBans.Contains(jobId)))
|
||||
continue;
|
||||
|
||||
availableJobs ??= new List<string>(profile.JobPriorities.Count);
|
||||
|
||||
availableJobs.Add(jobId);
|
||||
}
|
||||
|
||||
if (availableJobs is not null)
|
||||
outputDict.Add(player, availableJobs);
|
||||
}
|
||||
|
||||
return outputDict;
|
||||
}
|
||||
}
|
||||
498
Content.Server/Station/Systems/StationJobsSystem.cs
Normal file
498
Content.Server/Station/Systems/StationJobsSystem.cs
Normal file
@@ -0,0 +1,498 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Content.Server.GameTicking;
|
||||
using Content.Server.Station.Components;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.GameTicking;
|
||||
using Content.Shared.Preferences;
|
||||
using Content.Shared.Roles;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Player;
|
||||
using Robust.Shared.Random;
|
||||
|
||||
namespace Content.Server.Station.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Manages job slots for stations.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed partial class StationJobsSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
|
||||
[Dependency] private readonly IRobustRandom _random = default!;
|
||||
[Dependency] private readonly GameTicker _gameTicker = default!;
|
||||
[Dependency] private readonly StationSystem _stationSystem = default!;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize()
|
||||
{
|
||||
SubscribeLocalEvent<StationInitializedEvent>(OnStationInitialized);
|
||||
SubscribeLocalEvent<StationJobsComponent, StationRenamedEvent>(OnStationRenamed);
|
||||
SubscribeLocalEvent<StationJobsComponent, ComponentShutdown>(OnStationDeletion);
|
||||
SubscribeLocalEvent<PlayerJoinedLobbyEvent>(OnPlayerJoinedLobby);
|
||||
_configurationManager.OnValueChanged(CCVars.GameDisallowLateJoins, _ => UpdateJobsAvailable(), true);
|
||||
|
||||
InitializeRoundStart();
|
||||
}
|
||||
|
||||
public override void Update(float _)
|
||||
{
|
||||
if (_availableJobsDirty)
|
||||
{
|
||||
_cachedAvailableJobs = GenerateJobsAvailableEvent();
|
||||
RaiseNetworkEvent(_cachedAvailableJobs, Filter.Empty().AddPlayers(_gameTicker.PlayersInLobby.Keys));
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var stationJobs = AddComp<StationJobsComponent>(msg.Station);
|
||||
var stationData = Comp<StationDataComponent>(msg.Station);
|
||||
|
||||
if (stationData.StationConfig == null)
|
||||
return;
|
||||
|
||||
var mapJobList = stationData.StationConfig.AvailableJobs;
|
||||
|
||||
stationJobs.RoundStartTotalJobs = mapJobList.Values.Where(x => x[0] is not null && x[0] > 0).Sum(x => x[0]!.Value);
|
||||
stationJobs.MidRoundTotalJobs = mapJobList.Values.Where(x => x[1] is not null && x[1] > 0).Sum(x => x[1]!.Value);
|
||||
stationJobs.TotalJobs = stationJobs.MidRoundTotalJobs;
|
||||
stationJobs.JobList = mapJobList.ToDictionary(x => x.Key, x =>
|
||||
{
|
||||
if (x.Value[1] <= -1)
|
||||
return null;
|
||||
return (uint?) x.Value[1];
|
||||
});
|
||||
stationJobs.RoundStartJobList = mapJobList.ToDictionary(x => x.Key, x =>
|
||||
{
|
||||
if (x.Value[0] <= -1)
|
||||
return null;
|
||||
return (uint?) x.Value[0];
|
||||
});
|
||||
stationJobs.OverflowJobs = stationData.StationConfig.OverflowJobs.ToHashSet();
|
||||
UpdateJobsAvailable();
|
||||
}
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <inheritdoc cref="TryAssignJob(Robust.Shared.GameObjects.EntityUid,string,Content.Server.Station.Components.StationJobsComponent?)"/>
|
||||
/// <param name="station">Station to assign a job on.</param>
|
||||
/// <param name="job">Job to assign.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
public bool TryAssignJob(EntityUid station, JobPrototype job, StationJobsComponent? stationJobs = null)
|
||||
{
|
||||
return TryAssignJob(station, job.ID, stationJobs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to assign the given job once. (essentially, it decrements the slot if possible).
|
||||
/// </summary>
|
||||
/// <param name="station">Station to assign a job on.</param>
|
||||
/// <param name="jobPrototypeId">Job prototype ID to assign.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>Whether or not assignment was a success.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
public bool TryAssignJob(EntityUid station, string jobPrototypeId, StationJobsComponent? stationJobs = null)
|
||||
{
|
||||
return TryAdjustJobSlot(station, jobPrototypeId, -1, false, false, stationJobs);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="TryAdjustJobSlot(Robust.Shared.GameObjects.EntityUid,string,int,bool,bool,Content.Server.Station.Components.StationJobsComponent?)"/>
|
||||
/// <param name="station">Station to adjust the job slot on.</param>
|
||||
/// <param name="job">Job to adjust.</param>
|
||||
/// <param name="amount">Amount to adjust by.</param>
|
||||
/// <param name="createSlot">Whether or not it should create the slot if it doesn't exist.</param>
|
||||
/// <param name="clamp">Whether or not to clamp to zero if you'd remove more jobs than are available.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to adjust the given job slot by the amount provided.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to adjust the job slot on.</param>
|
||||
/// <param name="jobPrototypeId">Job prototype ID to adjust.</param>
|
||||
/// <param name="amount">Amount to adjust by.</param>
|
||||
/// <param name="createSlot">Whether or not it should create the slot if it doesn't exist.</param>
|
||||
/// <param name="clamp">Whether or not to clamp to zero if you'd remove more jobs than are available.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>Whether or not slot adjustment was a success.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
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.
|
||||
switch (jobList.ContainsKey(jobPrototypeId))
|
||||
{
|
||||
case false when amount < 0:
|
||||
return false;
|
||||
case false:
|
||||
if (!createSlot)
|
||||
return false;
|
||||
stationJobs.TotalJobs += amount;
|
||||
jobList[jobPrototypeId] = (uint?)amount;
|
||||
UpdateJobsAvailable();
|
||||
return true;
|
||||
case true:
|
||||
// Job is unlimited so just say we adjusted it and do nothing.
|
||||
if (jobList[jobPrototypeId] == null)
|
||||
return true;
|
||||
|
||||
// Would remove more jobs than we have available.
|
||||
if (amount < 0 && (jobList[jobPrototypeId] + amount < 0 && !clamp))
|
||||
return false;
|
||||
|
||||
stationJobs.TotalJobs += amount;
|
||||
|
||||
//C# type handling moment
|
||||
if (amount > 0)
|
||||
jobList[jobPrototypeId] += (uint)amount;
|
||||
else
|
||||
{
|
||||
if ((int)jobList[jobPrototypeId]!.Value - Math.Abs(amount) <= 0)
|
||||
jobList[jobPrototypeId] = 0;
|
||||
else
|
||||
jobList[jobPrototypeId] -= (uint) Math.Abs(amount);
|
||||
}
|
||||
|
||||
UpdateJobsAvailable();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="TrySetJobSlot(Robust.Shared.GameObjects.EntityUid,string,int,bool,Content.Server.Station.Components.StationJobsComponent?)"/>
|
||||
/// <param name="station">Station to adjust the job slot on.</param>
|
||||
/// <param name="jobPrototype">Job prototype to adjust.</param>
|
||||
/// <param name="amount">Amount to set to.</param>
|
||||
/// <param name="createSlot">Whether or not it should create the slot if it doesn't exist.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns></returns>
|
||||
public bool TrySetJobSlot(EntityUid station, JobPrototype jobPrototype, int amount, bool createSlot = false,
|
||||
StationJobsComponent? stationJobs = null)
|
||||
{
|
||||
return TrySetJobSlot(station, jobPrototype.ID, amount, createSlot, stationJobs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to set the given job slot to the amount provided.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to adjust the job slot on.</param>
|
||||
/// <param name="jobPrototypeId">Job prototype ID to adjust.</param>
|
||||
/// <param name="amount">Amount to set to.</param>
|
||||
/// <param name="createSlot">Whether or not it should create the slot if it doesn't exist.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>Whether or not setting the value succeeded.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
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] = (uint?)amount;
|
||||
UpdateJobsAvailable();
|
||||
return true;
|
||||
case true:
|
||||
// Job is unlimited so just say we adjusted it and do nothing.
|
||||
if (jobList[jobPrototypeId] == null)
|
||||
return true;
|
||||
|
||||
stationJobs.TotalJobs += amount - (int)jobList[jobPrototypeId]!.Value;
|
||||
|
||||
jobList[jobPrototypeId] = (uint)amount;
|
||||
UpdateJobsAvailable();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="MakeJobUnlimited(Robust.Shared.GameObjects.EntityUid,string,Content.Server.Station.Components.StationJobsComponent?)"/>
|
||||
/// <param name="station">Station to make a job unlimited on.</param>
|
||||
/// <param name="job">Job to make unlimited.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
public void MakeJobUnlimited(EntityUid station, JobPrototype job, StationJobsComponent? stationJobs = null)
|
||||
{
|
||||
MakeJobUnlimited(station, job.ID, stationJobs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes the given job have unlimited slots.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to make a job unlimited on.</param>
|
||||
/// <param name="jobPrototypeId">Job prototype ID to make unlimited.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
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.ContainsKey(jobPrototypeId) && stationJobs.JobList[jobPrototypeId] != null)
|
||||
stationJobs.TotalJobs -= (int)stationJobs.JobList[jobPrototypeId]!.Value;
|
||||
|
||||
stationJobs.JobList[jobPrototypeId] = null;
|
||||
|
||||
UpdateJobsAvailable();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IsJobUnlimited(Robust.Shared.GameObjects.EntityUid,string,Content.Server.Station.Components.StationJobsComponent?)"/>
|
||||
/// <param name="station">Station to check.</param>
|
||||
/// <param name="job">Job to check.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
public bool IsJobUnlimited(EntityUid station, JobPrototype job, StationJobsComponent? stationJobs = null)
|
||||
{
|
||||
return IsJobUnlimited(station, job.ID, stationJobs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the given job is unlimited.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to check.</param>
|
||||
/// <param name="jobPrototypeId">Job prototype ID to check.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>Returns if the given slot is unlimited.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
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));
|
||||
|
||||
var res = stationJobs.JobList.TryGetValue(jobPrototypeId, out var job) && job == null;
|
||||
return res;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="TryGetJobSlot(Robust.Shared.GameObjects.EntityUid,string,out System.Nullable{uint},Content.Server.Station.Components.StationJobsComponent?)"/>
|
||||
/// <param name="station">Station to get slot info from.</param>
|
||||
/// <param name="job">Job to get slot info for.</param>
|
||||
/// <param name="slots">The number of slots remaining. Null if infinite.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
public bool TryGetJobSlot(EntityUid station, JobPrototype job, out uint? slots, StationJobsComponent? stationJobs = null)
|
||||
{
|
||||
return TryGetJobSlot(station, job.ID, out slots, stationJobs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns information about the given job slot.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to get slot info from.</param>
|
||||
/// <param name="jobPrototypeId">Job prototype ID to get slot info for.</param>
|
||||
/// <param name="slots">The number of slots remaining. Null if infinite.</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>Whether or not the slot exists.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
/// <remarks>slots will be null if the slot doesn't exist, as well, so make sure to check the return value.</remarks>
|
||||
public bool TryGetJobSlot(EntityUid station, string jobPrototypeId, out uint? slots, StationJobsComponent? stationJobs = null)
|
||||
{
|
||||
if (!Resolve(station, ref stationJobs))
|
||||
throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station));
|
||||
|
||||
if (stationJobs.JobList.TryGetValue(jobPrototypeId, out var job))
|
||||
{
|
||||
slots = job;
|
||||
return true;
|
||||
}
|
||||
else // Else if slot isn't present return null.
|
||||
{
|
||||
slots = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all jobs available on the station.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to get jobs for</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>Set containing all jobs available.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
public IReadOnlySet<string> 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).ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all overflow jobs available on the station.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to get jobs for</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>Set containing all overflow jobs available.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
public IReadOnlySet<string> 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.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a readonly dictionary of all jobs and their slot info.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to get jobs for</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>List of all jobs on the station.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
public IReadOnlyDictionary<string, uint?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a readonly dictionary of all round-start jobs and their slot info.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to get jobs for</param>
|
||||
/// <param name="stationJobs">Resolve pattern, station jobs component of the station.</param>
|
||||
/// <returns>List of all round-start jobs.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
public IReadOnlyDictionary<string, uint?> 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.RoundStartJobList;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks at the given priority list, and picks the best available job (optionally with the given exclusions)
|
||||
/// </summary>
|
||||
/// <param name="station">Station to pick from.</param>
|
||||
/// <param name="jobPriorities">The priority list to use for selecting a job.</param>
|
||||
/// <param name="pickOverflows">Whether or not to pick from the overflow list.</param>
|
||||
/// <param name="disallowedJobs">A set of disallowed jobs, if any.</param>
|
||||
/// <returns>The selected job, if any.</returns>
|
||||
public string? PickBestAvailableJobWithPriority(EntityUid station, IReadOnlyDictionary<string, JobPriority> jobPriorities, bool pickOverflows, IReadOnlySet<string>? disallowedJobs = null)
|
||||
{
|
||||
if (station == EntityUid.Invalid)
|
||||
return null;
|
||||
|
||||
var available = GetAvailableJobs(station);
|
||||
bool TryPick(JobPriority priority, [NotNullWhen(true)] out string? 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);
|
||||
return overflows.Count != 0 ? _random.Pick(overflows) : null;
|
||||
}
|
||||
|
||||
#endregion Public API
|
||||
|
||||
#region Latejoin job management
|
||||
|
||||
private bool _availableJobsDirty;
|
||||
|
||||
private TickerJobsAvailableEvent _cachedAvailableJobs = new (new Dictionary<EntityUid, string>(), new Dictionary<EntityUid, Dictionary<string, uint?>>());
|
||||
|
||||
/// <summary>
|
||||
/// Assembles an event from the current available-to-play jobs.
|
||||
/// This is moderately expensive to construct.
|
||||
/// </summary>
|
||||
/// <returns>The event.</returns>
|
||||
private TickerJobsAvailableEvent GenerateJobsAvailableEvent()
|
||||
{
|
||||
// If late join is disallowed, return no available jobs.
|
||||
if (_gameTicker.DisallowLateJoin)
|
||||
return new TickerJobsAvailableEvent(new Dictionary<EntityUid, string>(), new Dictionary<EntityUid, Dictionary<string, uint?>>());
|
||||
|
||||
var jobs = new Dictionary<EntityUid, Dictionary<string, uint?>>();
|
||||
var stationNames = new Dictionary<EntityUid, string>();
|
||||
|
||||
foreach (var station in _stationSystem.Stations)
|
||||
{
|
||||
var list = Comp<StationJobsComponent>(station).JobList.ToDictionary(x => x.Key, x => x.Value);
|
||||
jobs.Add(station, list);
|
||||
stationNames.Add(station, Name(station));
|
||||
}
|
||||
return new TickerJobsAvailableEvent(stationNames, jobs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the cached available jobs. Moderately expensive.
|
||||
/// </summary>
|
||||
private void UpdateJobsAvailable()
|
||||
{
|
||||
_availableJobsDirty = true;
|
||||
}
|
||||
|
||||
private void OnPlayerJoinedLobby(PlayerJoinedLobbyEvent ev)
|
||||
{
|
||||
RaiseNetworkEvent(_cachedAvailableJobs, ev.PlayerSession.ConnectedClient);
|
||||
}
|
||||
|
||||
private void OnStationRenamed(EntityUid uid, StationJobsComponent component, StationRenamedEvent args)
|
||||
{
|
||||
UpdateJobsAvailable();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
206
Content.Server/Station/Systems/StationSpawningSystem.cs
Normal file
206
Content.Server/Station/Systems/StationSpawningSystem.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using Content.Server.Access.Systems;
|
||||
using Content.Server.CharacterAppearance.Systems;
|
||||
using Content.Server.Hands.Components;
|
||||
using Content.Server.Hands.Systems;
|
||||
using Content.Server.PDA;
|
||||
using Content.Server.Roles;
|
||||
using Content.Server.Station.Components;
|
||||
using Content.Shared.Access.Components;
|
||||
using Content.Shared.Inventory;
|
||||
using Content.Shared.PDA;
|
||||
using Content.Shared.Preferences;
|
||||
using Content.Shared.Roles;
|
||||
using Content.Shared.Species;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.Station.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Manages spawning into the game, tracking available spawn points.
|
||||
/// Also provides helpers for spawning in the player's mob.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed class StationSpawningSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IMapManager _mapManager = default!;
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
[Dependency] private readonly IRobustRandom _random = default!;
|
||||
[Dependency] private readonly HandsSystem _handsSystem = default!;
|
||||
[Dependency] private readonly HumanoidAppearanceSystem _humanoidAppearanceSystem = default!;
|
||||
[Dependency] private readonly IdCardSystem _cardSystem = default!;
|
||||
[Dependency] private readonly InventorySystem _inventorySystem = default!;
|
||||
[Dependency] private readonly PDASystem _pdaSystem = default!;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize()
|
||||
{
|
||||
SubscribeLocalEvent<StationInitializedEvent>(OnStationInitialized);
|
||||
}
|
||||
|
||||
private void OnStationInitialized(StationInitializedEvent ev)
|
||||
{
|
||||
AddComp<StationSpawningComponent>(ev.Station);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to spawn a player character onto the given station.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to spawn onto.</param>
|
||||
/// <param name="job">The job to assign, if any.</param>
|
||||
/// <param name="profile">The character profile to use, if any.</param>
|
||||
/// <param name="stationSpawning">Resolve pattern, the station spawning component for the station.</param>
|
||||
/// <returns>The resulting player character, if any.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
/// <remarks>
|
||||
/// This only spawns the character, and does none of the mind-related setup you'd need for it to be playable.
|
||||
/// </remarks>
|
||||
public EntityUid? SpawnPlayerCharacterOnStation(EntityUid? station, Job? job, HumanoidCharacterProfile? profile, StationSpawningComponent? stationSpawning = null)
|
||||
{
|
||||
if (station != null && !Resolve(station.Value, ref stationSpawning))
|
||||
throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station));
|
||||
|
||||
var ev = new PlayerSpawningEvent(job, profile, station);
|
||||
RaiseLocalEvent(ev);
|
||||
|
||||
DebugTools.Assert(ev.SpawnResult is {Valid: true} or null);
|
||||
|
||||
return ev.SpawnResult;
|
||||
}
|
||||
|
||||
//TODO: Figure out if everything in the player spawning region belongs somewhere else.
|
||||
#region Player spawning helpers
|
||||
|
||||
/// <summary>
|
||||
/// Spawns in a player's mob according to their job and character information at the given coordinates.
|
||||
/// Used by systems that need to handle spawning players.
|
||||
/// </summary>
|
||||
/// <param name="coordinates">Coordinates to spawn the character at.</param>
|
||||
/// <param name="job">Job to assign to the character, if any.</param>
|
||||
/// <param name="profile">Appearance profile to use for the character.</param>
|
||||
/// <returns>The spawned entity</returns>
|
||||
public EntityUid SpawnPlayerMob(EntityCoordinates coordinates, Job? job, HumanoidCharacterProfile? profile)
|
||||
{
|
||||
var entity = EntityManager.SpawnEntity(
|
||||
_prototypeManager.Index<SpeciesPrototype>(profile?.Species ?? SpeciesManager.DefaultSpecies).Prototype,
|
||||
coordinates);
|
||||
|
||||
if (job?.StartingGear != null)
|
||||
{
|
||||
var startingGear = _prototypeManager.Index<StartingGearPrototype>(job.StartingGear);
|
||||
EquipStartingGear(entity, startingGear, profile);
|
||||
if (profile != null)
|
||||
EquipIdCard(entity, profile.Name, job.Prototype);
|
||||
}
|
||||
|
||||
if (profile != null)
|
||||
{
|
||||
_humanoidAppearanceSystem.UpdateFromProfile(entity, profile);
|
||||
EntityManager.GetComponent<MetaDataComponent>(entity).EntityName = profile.Name;
|
||||
}
|
||||
|
||||
foreach (var jobSpecial in job?.Prototype.Special ?? Array.Empty<JobSpecial>())
|
||||
{
|
||||
jobSpecial.AfterEquip(entity);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equips starting gear onto the given entity.
|
||||
/// </summary>
|
||||
/// <param name="entity">Entity to load out.</param>
|
||||
/// <param name="startingGear">Starting gear to use.</param>
|
||||
/// <param name="profile">Character profile to use, if any.</param>
|
||||
public void EquipStartingGear(EntityUid entity, StartingGearPrototype startingGear, HumanoidCharacterProfile? profile)
|
||||
{
|
||||
if (_inventorySystem.TryGetSlots(entity, out var slotDefinitions))
|
||||
{
|
||||
foreach (var slot in slotDefinitions)
|
||||
{
|
||||
var equipmentStr = startingGear.GetGear(slot.Name, profile);
|
||||
if (!string.IsNullOrEmpty(equipmentStr))
|
||||
{
|
||||
var equipmentEntity = EntityManager.SpawnEntity(equipmentStr, EntityManager.GetComponent<TransformComponent>(entity).Coordinates);
|
||||
_inventorySystem.TryEquip(entity, equipmentEntity, slot.Name, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryComp(entity, out HandsComponent? handsComponent))
|
||||
return;
|
||||
|
||||
var inhand = startingGear.Inhand;
|
||||
var coords = EntityManager.GetComponent<TransformComponent>(entity).Coordinates;
|
||||
foreach (var (hand, prototype) in inhand)
|
||||
{
|
||||
var inhandEntity = EntityManager.SpawnEntity(prototype, coords);
|
||||
_handsSystem.TryPickup(entity, inhandEntity, hand, checkActionBlocker: false, handsComp: handsComponent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equips an ID card and PDA onto the given entity.
|
||||
/// </summary>
|
||||
/// <param name="entity">Entity to load out.</param>
|
||||
/// <param name="characterName">Character name to use for the ID.</param>
|
||||
/// <param name="jobPrototype">Job prototype to use for the PDA and ID.</param>
|
||||
public void EquipIdCard(EntityUid entity, string characterName, JobPrototype jobPrototype)
|
||||
{
|
||||
if (!_inventorySystem.TryGetSlotEntity(entity, "id", out var idUid))
|
||||
return;
|
||||
|
||||
if (!EntityManager.TryGetComponent(idUid, out PDAComponent? pdaComponent) || pdaComponent.ContainedID == null)
|
||||
return;
|
||||
|
||||
var card = pdaComponent.ContainedID;
|
||||
_cardSystem.TryChangeFullName(card.Owner, characterName, card);
|
||||
_cardSystem.TryChangeJobTitle(card.Owner, jobPrototype.Name, card);
|
||||
|
||||
var access = EntityManager.GetComponent<AccessComponent>(card.Owner);
|
||||
var accessTags = access.Tags;
|
||||
accessTags.UnionWith(jobPrototype.Access);
|
||||
_pdaSystem.SetOwner(pdaComponent, characterName);
|
||||
}
|
||||
|
||||
|
||||
#endregion Player spawning helpers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordered broadcast event fired on any spawner eligible to attempt to spawn a player.
|
||||
/// This event's success is measured by if SpawnResult is not null.
|
||||
/// You should not make this event's success rely on random chance.
|
||||
/// This event is designed to use ordered handling. You probably want SpawnPointSystem to be the last handler.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed class PlayerSpawningEvent : EntityEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// The entity spawned, if any. You should set this if you succeed at spawning the character, and leave it alone if it's not null.
|
||||
/// </summary>
|
||||
public EntityUid? SpawnResult;
|
||||
/// <summary>
|
||||
/// The job to use, if any.
|
||||
/// </summary>
|
||||
public readonly Job? Job;
|
||||
/// <summary>
|
||||
/// The profile to use, if any.
|
||||
/// </summary>
|
||||
public readonly HumanoidCharacterProfile? HumanoidCharacterProfile;
|
||||
/// <summary>
|
||||
/// The target station, if any.
|
||||
/// </summary>
|
||||
public readonly EntityUid? Station;
|
||||
|
||||
public PlayerSpawningEvent(Job? job, HumanoidCharacterProfile? humanoidCharacterProfile, EntityUid? station)
|
||||
{
|
||||
Job = job;
|
||||
HumanoidCharacterProfile = humanoidCharacterProfile;
|
||||
Station = station;
|
||||
}
|
||||
}
|
||||
407
Content.Server/Station/Systems/StationSystem.cs
Normal file
407
Content.Server/Station/Systems/StationSystem.cs
Normal file
@@ -0,0 +1,407 @@
|
||||
using System.Linq;
|
||||
using Content.Server.Chat.Managers;
|
||||
using Content.Server.GameTicking;
|
||||
using Content.Server.Maps;
|
||||
using Content.Server.Station.Components;
|
||||
using Content.Shared.CCVar;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Random;
|
||||
|
||||
namespace Content.Server.Station.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// System that manages stations.
|
||||
/// A station is, by default, just a name, optional map prototype, and optional grids.
|
||||
/// For jobs, look at StationJobSystem. For spawning, look at StationSpawningSystem.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed class StationSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IChatManager _chatManager = default!;
|
||||
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
|
||||
[Dependency] private readonly IGameMapManager _gameMapManager = default!;
|
||||
[Dependency] private readonly ILogManager _logManager = default!;
|
||||
[Dependency] private readonly IMapManager _mapManager = default!;
|
||||
[Dependency] private readonly IRobustRandom _random = default!;
|
||||
[Dependency] private readonly GameTicker _gameTicker = default!;
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
|
||||
private readonly HashSet<EntityUid> _stations = new();
|
||||
|
||||
/// <summary>
|
||||
/// All stations that currently exist.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// I'd have this just invoke an entity query, but I want this to be a hashset for convenience and it allocating on use would be lame.
|
||||
/// </remarks>
|
||||
public IReadOnlySet<EntityUid> Stations => _stations;
|
||||
|
||||
private bool _randomStationOffset;
|
||||
private bool _randomStationRotation;
|
||||
private float _maxRandomStationOffset;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize()
|
||||
{
|
||||
_sawmill = _logManager.GetSawmill("station");
|
||||
|
||||
SubscribeLocalEvent<GameRunLevelChangedEvent>(OnRoundEnd);
|
||||
SubscribeLocalEvent<PreGameMapLoad>(OnPreGameMapLoad);
|
||||
SubscribeLocalEvent<PostGameMapLoad>(OnPostGameMapLoad);
|
||||
SubscribeLocalEvent<StationDataComponent, ComponentAdd>(OnStationStartup);
|
||||
SubscribeLocalEvent<StationDataComponent, ComponentShutdown>(OnStationDeleted);
|
||||
|
||||
_configurationManager.OnValueChanged(CCVars.StationOffset, x => _randomStationOffset = x, true);
|
||||
_configurationManager.OnValueChanged(CCVars.MaxStationOffset, x => _maxRandomStationOffset = x, true);
|
||||
_configurationManager.OnValueChanged(CCVars.StationRotation, x => _randomStationRotation = x, true);
|
||||
}
|
||||
|
||||
#region Event handlers
|
||||
|
||||
private void OnStationStartup(EntityUid uid, StationDataComponent component, ComponentAdd args)
|
||||
{
|
||||
_stations.Add(uid);
|
||||
}
|
||||
|
||||
private void OnStationDeleted(EntityUid uid, StationDataComponent component, ComponentShutdown args)
|
||||
{
|
||||
_stations.Remove(uid);
|
||||
}
|
||||
|
||||
private void OnPreGameMapLoad(PreGameMapLoad ev)
|
||||
{
|
||||
// this is only for maps loaded during round setup!
|
||||
if (_gameTicker.RunLevel == GameRunLevel.InRound)
|
||||
return;
|
||||
|
||||
if (_randomStationOffset)
|
||||
ev.Options.Offset += _random.NextVector2(_maxRandomStationOffset);
|
||||
|
||||
if (_randomStationRotation)
|
||||
ev.Options.Rotation = _random.NextAngle();
|
||||
}
|
||||
|
||||
private void OnPostGameMapLoad(PostGameMapLoad ev)
|
||||
{
|
||||
var dict = new Dictionary<string, List<GridId>>();
|
||||
|
||||
void AddGrid(string station, GridId grid)
|
||||
{
|
||||
if (dict.ContainsKey(station))
|
||||
{
|
||||
dict[station].Add(grid);
|
||||
}
|
||||
else
|
||||
{
|
||||
dict[station] = new List<GridId> {grid};
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over all BecomesStation
|
||||
foreach (var grid in ev.Grids)
|
||||
{
|
||||
// We still setup the grid
|
||||
if (!TryComp<BecomesStationComponent>(_mapManager.GetGridEuid(grid), out var becomesStation))
|
||||
continue;
|
||||
|
||||
AddGrid(becomesStation.Id, grid);
|
||||
}
|
||||
|
||||
if (!dict.Any())
|
||||
{
|
||||
// Oh jeez, no stations got loaded.
|
||||
// We'll just take the first grid and setup that, then.
|
||||
|
||||
var grid = ev.Grids[0];
|
||||
|
||||
AddGrid("Station", grid);
|
||||
}
|
||||
|
||||
// Iterate over all PartOfStation
|
||||
foreach (var grid in ev.Grids)
|
||||
{
|
||||
if (!TryComp<PartOfStationComponent>(_mapManager.GetGridEuid(grid), out var partOfStation))
|
||||
continue;
|
||||
|
||||
AddGrid(partOfStation.Id, grid);
|
||||
}
|
||||
|
||||
foreach (var (id, gridIds) in dict)
|
||||
{
|
||||
StationConfig? stationConfig = null;
|
||||
if (ev.GameMap.Stations.ContainsKey(id))
|
||||
stationConfig = ev.GameMap.Stations[id];
|
||||
else
|
||||
_sawmill.Error($"The station {id} in map {ev.GameMap.ID} does not have an associated station config!");
|
||||
InitializeNewStation(stationConfig, gridIds.Select(x => _mapManager.GetGridEuid(x)), ev.StationName);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoundEnd(GameRunLevelChangedEvent eventArgs)
|
||||
{
|
||||
if (eventArgs.New != GameRunLevel.PreRoundLobby) return;
|
||||
|
||||
foreach (var entity in _stations)
|
||||
{
|
||||
Del(entity);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Event handlers
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Generates a station name from the given config.
|
||||
/// </summary>
|
||||
/// <param name="config"></param>
|
||||
/// <returns></returns>
|
||||
public static string GenerateStationName(StationConfig config)
|
||||
{
|
||||
return config.NameGenerator is not null
|
||||
? config.NameGenerator.FormatName(config.StationNameTemplate)
|
||||
: config.StationNameTemplate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new station with the given information.
|
||||
/// </summary>
|
||||
/// <param name="stationConfig">The game map prototype used, if any.</param>
|
||||
/// <param name="gridIds">All grids that should be added to the station.</param>
|
||||
/// <param name="name">Optional override for the station name.</param>
|
||||
/// <returns>The initialized station.</returns>
|
||||
public EntityUid InitializeNewStation(StationConfig? stationConfig, IEnumerable<EntityUid>? gridIds, string? name = null)
|
||||
{
|
||||
//HACK: This needs to go in null-space but that crashes currently.
|
||||
var station = Spawn(null, new MapCoordinates(0, 0, _gameTicker.DefaultMap));
|
||||
var data = AddComp<StationDataComponent>(station);
|
||||
var metaData = MetaData(station);
|
||||
data.StationConfig = stationConfig;
|
||||
|
||||
if (stationConfig is not null && name is null)
|
||||
{
|
||||
metaData.EntityName = GenerateStationName(stationConfig);
|
||||
}
|
||||
else if (name is not null)
|
||||
{
|
||||
metaData.EntityName = name;
|
||||
}
|
||||
else
|
||||
{
|
||||
_sawmill.Error($"When setting up station {station}, was unable to find a valid name in the config and no name was provided.");
|
||||
metaData.EntityName = "unnamed station";
|
||||
}
|
||||
|
||||
RaiseLocalEvent(new StationInitializedEvent(station));
|
||||
_sawmill.Info($"Set up station {metaData.EntityName} ({station}).");
|
||||
|
||||
foreach (var grid in gridIds ?? Array.Empty<EntityUid>())
|
||||
{
|
||||
AddGridToStation(station, grid, null, data);
|
||||
}
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the given grid to a station.
|
||||
/// </summary>
|
||||
/// <param name="mapGrid">Grid to attach.</param>
|
||||
/// <param name="station">Station to attach the grid to.</param>
|
||||
/// <param name="gridComponent">Resolve pattern, grid component of mapGrid.</param>
|
||||
/// <param name="stationData">Resolve pattern, station data component of station.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when mapGrid or station are not a grid or station, respectively.</exception>
|
||||
public void AddGridToStation(EntityUid station, EntityUid mapGrid, IMapGridComponent? gridComponent = null, StationDataComponent? stationData = null)
|
||||
{
|
||||
if (!Resolve(mapGrid, ref gridComponent))
|
||||
throw new ArgumentException("Tried to initialize a station on a non-grid entity!", nameof(mapGrid));
|
||||
if (!Resolve(station, ref stationData))
|
||||
throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station));
|
||||
|
||||
var stationMember = AddComp<StationMemberComponent>(mapGrid);
|
||||
stationMember.Station = station;
|
||||
stationData.Grids.Add(gridComponent.GridIndex);
|
||||
|
||||
RaiseLocalEvent(station, new StationGridAddedEvent(gridComponent.GridIndex, false));
|
||||
|
||||
_sawmill.Info($"Adding grid {mapGrid}:{gridComponent.GridIndex} to station {Name(station)} ({station})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the given grid from a station.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to remove the grid from.</param>
|
||||
/// <param name="mapGrid">Grid to remove</param>
|
||||
/// <param name="gridComponent">Resolve pattern, grid component of mapGrid.</param>
|
||||
/// <param name="stationData">Resolve pattern, station data component of station.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when mapGrid or station are not a grid or station, respectively.</exception>
|
||||
public void RemoveGridFromStation(EntityUid station, EntityUid mapGrid, IMapGridComponent? gridComponent = null, StationDataComponent? stationData = null)
|
||||
{
|
||||
if (!Resolve(mapGrid, ref gridComponent))
|
||||
throw new ArgumentException("Tried to initialize a station on a non-grid entity!", nameof(mapGrid));
|
||||
if (!Resolve(station, ref stationData))
|
||||
throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station));
|
||||
|
||||
RemComp<StationMemberComponent>(mapGrid);
|
||||
stationData.Grids.Remove(gridComponent.GridIndex);
|
||||
|
||||
RaiseLocalEvent(station, new StationGridRemovedEvent(gridComponent.GridIndex));
|
||||
_sawmill.Info($"Removing grid {mapGrid}:{gridComponent.GridIndex} from station {Name(station)} ({station})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames the given station.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to rename.</param>
|
||||
/// <param name="name">The new name to apply.</param>
|
||||
/// <param name="loud">Whether or not to announce the rename.</param>
|
||||
/// <param name="stationData">Resolve pattern, station data component of station.</param>
|
||||
/// <param name="metaData">Resolve pattern, metadata component of station.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
public void RenameStation(EntityUid station, string name, bool loud = true, StationDataComponent? stationData = null, MetaDataComponent? metaData = null)
|
||||
{
|
||||
if (!Resolve(station, ref stationData, ref metaData))
|
||||
throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station));
|
||||
|
||||
var oldName = metaData.EntityName;
|
||||
metaData.EntityName = name;
|
||||
|
||||
if (loud)
|
||||
{
|
||||
_chatManager.DispatchStationAnnouncement($"The station {oldName} has been renamed to {name}.");
|
||||
}
|
||||
|
||||
RaiseLocalEvent(station, new StationRenamedEvent(oldName, name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the given station.
|
||||
/// </summary>
|
||||
/// <param name="station">Station to delete.</param>
|
||||
/// <param name="stationData">Resolve pattern, station data component of station.</param>
|
||||
/// <exception cref="ArgumentException">Thrown when the given station is not a station.</exception>
|
||||
public void DeleteStation(EntityUid station, StationDataComponent? stationData = null)
|
||||
{
|
||||
if (!Resolve(station, ref stationData))
|
||||
throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station));
|
||||
|
||||
_stations.Remove(station);
|
||||
Del(station);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the station that "owns" the given entity (essentially, the station the grid it's on is attached to)
|
||||
/// </summary>
|
||||
/// <param name="entity">Entity to find the owner of.</param>
|
||||
/// <param name="xform">Resolve pattern, transform of the entity.</param>
|
||||
/// <returns>The owning station, if any.</returns>
|
||||
/// <remarks>
|
||||
/// This does not remember what station an entity started on, it simply checks where it is currently located.
|
||||
/// </remarks>
|
||||
public EntityUid? GetOwningStation(EntityUid entity, TransformComponent? xform = null)
|
||||
{
|
||||
if (!Resolve(entity, ref xform))
|
||||
throw new ArgumentException("Tried to use an abstract entity!", nameof(entity));
|
||||
|
||||
if (TryComp<IMapGridComponent>(entity, out _))
|
||||
{
|
||||
// We are the station, just check ourselves.
|
||||
return CompOrNull<StationMemberComponent>(entity)?.Station;
|
||||
}
|
||||
|
||||
if (xform.GridID == GridId.Invalid)
|
||||
{
|
||||
Logger.Debug("A");
|
||||
return null;
|
||||
}
|
||||
|
||||
var grid = _mapManager.GetGridEuid(xform.GridID);
|
||||
|
||||
return CompOrNull<StationMemberComponent>(grid)?.Station;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast event fired when a station is first set up.
|
||||
/// This is the ideal point to add components to it.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed class StationInitializedEvent : EntityEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Station this event is for.
|
||||
/// </summary>
|
||||
public EntityUid Station;
|
||||
|
||||
public StationInitializedEvent(EntityUid station)
|
||||
{
|
||||
Station = station;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directed event fired on a station when a grid becomes a member of the station.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed class StationGridAddedEvent : EntityEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the grid added to the station.
|
||||
/// </summary>
|
||||
public GridId GridId;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the event was fired during station setup,
|
||||
/// so that it can be ignored if StationInitializedEvent was already handled.
|
||||
/// </summary>
|
||||
public bool IsSetup;
|
||||
|
||||
public StationGridAddedEvent(GridId gridId, bool isSetup)
|
||||
{
|
||||
GridId = gridId;
|
||||
IsSetup = isSetup;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directed event fired on a station when a grid is no longer a member of the station.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed class StationGridRemovedEvent : EntityEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the grid removed from the station.
|
||||
/// </summary>
|
||||
public GridId GridId;
|
||||
|
||||
public StationGridRemovedEvent(GridId gridId)
|
||||
{
|
||||
GridId = gridId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directed event fired on a station when it is renamed.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public sealed class StationRenamedEvent : EntityEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Prior name of the station.
|
||||
/// </summary>
|
||||
public string OldName;
|
||||
|
||||
/// <summary>
|
||||
/// New name of the station.
|
||||
/// </summary>
|
||||
public string NewName;
|
||||
|
||||
public StationRenamedEvent(string oldName, string newName)
|
||||
{
|
||||
OldName = oldName;
|
||||
NewName = newName;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user