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:
Moony
2022-05-10 13:43:30 -05:00
committed by GitHub
parent d234a79d28
commit 36181334b5
65 changed files with 2564 additions and 1368 deletions

View 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;
}
}

View 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
}

View 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;
}
}

View 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;
}
}