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

@@ -3,7 +3,6 @@ using Content.Client.RoundEnd;
using Content.Client.Viewport;
using Content.Shared.GameTicking;
using Content.Shared.GameWindow;
using Content.Shared.Station;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Client.State;
@@ -17,8 +16,8 @@ namespace Content.Client.GameTicking.Managers
{
[Dependency] private readonly IStateManager _stateManager = default!;
[ViewVariables] private bool _initialized;
private Dictionary<StationId, Dictionary<string, int>> _jobsAvailable = new();
private Dictionary<StationId, string> _stationNames = new();
private Dictionary<EntityUid, Dictionary<string, uint?>> _jobsAvailable = new();
private Dictionary<EntityUid, string> _stationNames = new();
[ViewVariables] public bool AreWeReady { get; private set; }
[ViewVariables] public bool IsGameStarted { get; private set; }
@@ -29,14 +28,14 @@ namespace Content.Client.GameTicking.Managers
[ViewVariables] public TimeSpan StartTime { get; private set; }
[ViewVariables] public new bool Paused { get; private set; }
[ViewVariables] public Dictionary<NetUserId, LobbyPlayerStatus> Status { get; private set; } = new();
[ViewVariables] public IReadOnlyDictionary<StationId, Dictionary<string, int>> JobsAvailable => _jobsAvailable;
[ViewVariables] public IReadOnlyDictionary<StationId, string> StationNames => _stationNames;
[ViewVariables] public IReadOnlyDictionary<EntityUid, Dictionary<string, uint?>> JobsAvailable => _jobsAvailable;
[ViewVariables] public IReadOnlyDictionary<EntityUid, string> StationNames => _stationNames;
public event Action? InfoBlobUpdated;
public event Action? LobbyStatusUpdated;
public event Action? LobbyReadyUpdated;
public event Action? LobbyLateJoinStatusUpdated;
public event Action<IReadOnlyDictionary<StationId, Dictionary<string, int>>>? LobbyJobsAvailableUpdated;
public event Action<IReadOnlyDictionary<EntityUid, Dictionary<string, uint?>>>? LobbyJobsAvailableUpdated;
public override void Initialize()
{

View File

@@ -1,20 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Content.Client.GameTicking.Managers;
using Content.Client.HUD.UI;
using Content.Shared.Roles;
using Content.Shared.Station;
using Robust.Client.Console;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.Utility;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Log;
using Robust.Shared.Maths;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using static Robust.Client.UserInterface.Controls.BoxContainer;
@@ -26,10 +18,10 @@ namespace Content.Client.LateJoin
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IClientConsoleHost _consoleHost = default!;
public event Action<(StationId, string)> SelectedId;
public event Action<(EntityUid, string)> SelectedId;
private readonly Dictionary<StationId, Dictionary<string, JobButton>> _jobButtons = new();
private readonly Dictionary<StationId, Dictionary<string, BoxContainer>> _jobCategories = new();
private readonly Dictionary<EntityUid, Dictionary<string, JobButton>> _jobButtons = new();
private readonly Dictionary<EntityUid, Dictionary<string, BoxContainer>> _jobCategories = new();
private readonly List<ScrollContainer> _jobLists = new();
private readonly Control _base;
@@ -57,7 +49,7 @@ namespace Content.Client.LateJoin
{
var (station, jobId) = x;
Logger.InfoS("latejoin", $"Late joining as ID: {jobId}");
_consoleHost.ExecuteCommand($"joingame {CommandParsing.Escape(jobId)} {station.Id}");
_consoleHost.ExecuteCommand($"joingame {CommandParsing.Escape(jobId)} {station}");
Close();
};
@@ -209,7 +201,7 @@ namespace Content.Client.LateJoin
var jobLabel = new Label
{
Text = job.Value >= 0 ?
Text = job.Value != null ?
Loc.GetString("late-join-gui-job-slot-capped", ("jobName", prototype.Name), ("amount", job.Value)) :
Loc.GetString("late-join-gui-job-slot-uncapped", ("jobName", prototype.Name))
};
@@ -234,8 +226,9 @@ namespace Content.Client.LateJoin
}
}
private void JobsAvailableUpdated(IReadOnlyDictionary<StationId, Dictionary<string, int>> _)
private void JobsAvailableUpdated(IReadOnlyDictionary<EntityUid, Dictionary<string, uint?>> _)
{
Logger.Debug("UI rebuilt.");
RebuildUI();
}
@@ -255,9 +248,9 @@ namespace Content.Client.LateJoin
sealed class JobButton : ContainerButton
{
public string JobId { get; }
public int Amount { get; }
public uint? Amount { get; }
public JobButton(string jobId, int amount)
public JobButton(string jobId, uint? amount)
{
JobId = jobId;
Amount = amount;

View File

@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Content.Server.Administration.Managers;
using Content.Server.Maps;
using Content.Server.Station.Systems;
using Content.Shared.Preferences;
using NUnit.Framework;
using Robust.Server;
using Robust.Shared.GameObjects;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
namespace Content.IntegrationTests.Tests.Station;
[TestFixture]
[TestOf(typeof(StationJobsSystem))]
public sealed class StationJobsTest : ContentIntegrationTest
{
private const string Prototypes = @"
- type: gameMap
id: FooStation
minPlayers: 0
mapName: FooStation
mapPath: Maps/Tests/empty.yml
stations:
Station:
mapNameTemplate: FooStation
overflowJobs:
- Assistant
availableJobs:
TMime: [0, -1]
TAssistant: [-1, -1]
TCaptain: [5, 5]
TClown: [5, 6]
- type: job
id: TAssistant
- type: job
id: TMime
weight: 20
- type: job
id: TClown
weight: -10
- type: job
id: TCaptain
weight: 10
- type: job
id: TChaplain
";
private const int StationCount = 100;
private const int CaptainCount = StationCount;
private const int PlayerCount = 2000;
private const int TotalPlayers = PlayerCount + CaptainCount;
[Test]
public async Task AssignJobsTest()
{
var options = new ServerContentIntegrationOption {ExtraPrototypes = Prototypes, Options = new ServerOptions() { LoadContentResources = false }};
var server = StartServer(options);
await server.WaitIdleAsync();
var prototypeManager = server.ResolveDependency<IPrototypeManager>();
var mapManager = server.ResolveDependency<IMapManager>();
var fooStationProto = prototypeManager.Index<GameMapPrototype>("FooStation");
var entSysMan = server.ResolveDependency<IEntityManager>().EntitySysManager;
var stationJobs = entSysMan.GetEntitySystem<StationJobsSystem>();
var stationSystem = entSysMan.GetEntitySystem<StationSystem>();
List<EntityUid> stations = new();
await server.WaitPost(() =>
{
mapManager.CreateNewMapEntity(MapId.Nullspace);
for (var i = 0; i < StationCount; i++)
{
stations.Add(stationSystem.InitializeNewStation(fooStationProto.Stations["Station"], null, $"Foo {StationCount}"));
}
});
await server.WaitAssertion(() =>
{
var fakePlayers = new Dictionary<NetUserId, HumanoidCharacterProfile>()
.AddJob("TAssistant", JobPriority.Medium, PlayerCount)
.AddPreference("TClown", JobPriority.Low)
.AddPreference("TMime", JobPriority.High)
.WithPlayers(
new Dictionary<NetUserId, HumanoidCharacterProfile>()
.AddJob("TCaptain", JobPriority.High, CaptainCount)
);
var start = new Stopwatch();
start.Start();
var assigned = stationJobs.AssignJobs(fakePlayers, stations);
var time = start.Elapsed.TotalMilliseconds;
Logger.Info($"Took {time} ms to distribute {TotalPlayers} players.");
foreach (var station in stations)
{
var assignedHere = assigned
.Where(x => x.Value.Item2 == station)
.ToDictionary(x => x.Key, x => x.Value);
// Each station should have SOME players.
Assert.That(assignedHere, Is.Not.Empty);
// And it should have at least the minimum players to be considered a "fair" share, as they're all the same.
Assert.That(assignedHere, Has.Count.GreaterThanOrEqualTo(TotalPlayers/stations.Count), "Station has too few players.");
// And it shouldn't have ALL the players, either.
Assert.That(assignedHere, Has.Count.LessThan(TotalPlayers), "Station has too many players.");
// And there should be *A* captain, as there's one player with captain enabled per station.
Assert.That(assignedHere.Where(x => x.Value.Item1 == "TCaptain").ToList(), Has.Count.EqualTo(1));
}
// All clown players have assistant as a higher priority.
Assert.That(assigned.Values.Select(x => x.Item1).ToList(), Does.Not.Contain("TClown"));
// Mime isn't an open job-slot at round-start.
Assert.That(assigned.Values.Select(x => x.Item1).ToList(), Does.Not.Contain("TMime"));
// All players have slots they can fill.
Assert.That(assigned.Values, Has.Count.EqualTo(TotalPlayers), $"Expected {TotalPlayers} players.");
// There must be assistants present.
Assert.That(assigned.Values.Select(x => x.Item1).ToList(), Does.Contain("TAssistant"));
// There must be captains present, too.
Assert.That(assigned.Values.Select(x => x.Item1).ToList(), Does.Contain("TCaptain"));
});
}
[Test]
public async Task AdjustJobsTest()
{
var options = new ServerContentIntegrationOption {ExtraPrototypes = Prototypes, Options = new ServerOptions() { LoadContentResources = false }};
var server = StartServer(options);
await server.WaitIdleAsync();
var prototypeManager = server.ResolveDependency<IPrototypeManager>();
var mapManager = server.ResolveDependency<IMapManager>();
var fooStationProto = prototypeManager.Index<GameMapPrototype>("FooStation");
var entSysMan = server.ResolveDependency<IEntityManager>().EntitySysManager;
var stationJobs = entSysMan.GetEntitySystem<StationJobsSystem>();
var stationSystem = entSysMan.GetEntitySystem<StationSystem>();
var station = EntityUid.Invalid;
await server.WaitPost(() =>
{
mapManager.CreateNewMapEntity(MapId.Nullspace);
station = stationSystem.InitializeNewStation(fooStationProto.Stations["Station"], null, $"Foo Station");
});
await server.WaitAssertion(() =>
{
// Verify jobs are/are not unlimited.
Assert.Multiple(() =>
{
Assert.That(stationJobs.IsJobUnlimited(station, "TAssistant"), "TAssistant is expected to be unlimited.");
Assert.That(stationJobs.IsJobUnlimited(station, "TMime"), "TMime is expected to be unlimited.");
Assert.That(!stationJobs.IsJobUnlimited(station, "TCaptain"), "TCaptain is expected to not be unlimited.");
Assert.That(!stationJobs.IsJobUnlimited(station, "TClown"), "TClown is expected to not be unlimited.");
});
Assert.Multiple(() =>
{
Assert.That(stationJobs.TrySetJobSlot(station, "TClown", 0), "Could not set TClown to have zero slots.");
Assert.That(stationJobs.TryGetJobSlot(station, "TClown", out var clownSlots), "Could not get the number of TClown slots.");
Assert.That(clownSlots, Is.EqualTo(0));
Assert.That(!stationJobs.TryAdjustJobSlot(station, "TCaptain", -9999), "Was able to adjust TCaptain by -9999 without clamping.");
Assert.That(stationJobs.TryAdjustJobSlot(station, "TCaptain", -9999, false, true), "Could not adjust TCaptain by -9999.");
Assert.That(stationJobs.TryGetJobSlot(station, "TCaptain", out var captainSlots), "Could not get the number of TCaptain slots.");
Assert.That(captainSlots, Is.EqualTo(0));
});
Assert.Multiple(() =>
{
Assert.That(stationJobs.TrySetJobSlot(station, "TChaplain", 10, true), "Could not create 10 TChaplain slots.");
stationJobs.MakeJobUnlimited(station, "TChaplain");
Assert.That(stationJobs.IsJobUnlimited(station, "TChaplain"), "Could not make TChaplain unlimited.");
});
});
}
}
internal static class JobExtensions
{
public static Dictionary<NetUserId, HumanoidCharacterProfile> AddJob(
this Dictionary<NetUserId, HumanoidCharacterProfile> inp, string jobId, JobPriority prio = JobPriority.Medium,
int amount = 1)
{
for (var i = 0; i < amount; i++)
{
inp.Add(new NetUserId(Guid.NewGuid()), HumanoidCharacterProfile.Random().WithJobPriority(jobId, prio));
}
return inp;
}
public static Dictionary<NetUserId, HumanoidCharacterProfile> AddPreference(
this Dictionary<NetUserId, HumanoidCharacterProfile> inp, string jobId, JobPriority prio = JobPriority.Medium)
{
return inp.ToDictionary(x => x.Key, x => x.Value.WithJobPriority(jobId, prio));
}
public static Dictionary<NetUserId, HumanoidCharacterProfile> WithPlayers(
this Dictionary<NetUserId, HumanoidCharacterProfile> inp,
Dictionary<NetUserId, HumanoidCharacterProfile> second)
{
return new[] {inp, second}.SelectMany(x => x).ToDictionary(x => x.Key, x => x.Value);
}
}

View File

@@ -1,6 +1,5 @@
using Content.Server.AI.EntitySystems;
using Content.Server.GameTicking;
using Content.Shared.ActionBlocker;
using Content.Server.Station.Systems;
using Content.Shared.Movement.Components;
using Content.Shared.Roles;
using Robust.Shared.Map;
@@ -66,11 +65,11 @@ namespace Content.Server.AI.Components
if (StartingGearPrototype != null)
{
var gameTicker = EntitySystem.Get<GameTicker>();
var stationSpawning = EntitySystem.Get<StationSpawningSystem>();
var protoManager = IoCManager.Resolve<IPrototypeManager>();
var startingGear = protoManager.Index<StartingGearPrototype>(StartingGearPrototype);
gameTicker.EquipStartingGear(Owner, startingGear, null);
stationSpawning.EquipStartingGear(Owner, startingGear, null);
}
}

View File

@@ -1,11 +1,7 @@
using Content.Server.Station;
using Content.Server.Station.Systems;
using Content.Shared.Administration;
using Content.Shared.Roles;
using Content.Shared.Station;
using Robust.Shared.Console;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Prototypes;
namespace Content.Server.Administration.Commands.Station;
@@ -27,16 +23,17 @@ public sealed class AdjustStationJobCommand : IConsoleCommand
return;
}
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
var stationSystem = EntitySystem.Get<StationSystem>();
var stationJobs = EntitySystem.Get<StationJobsSystem>();
if (!uint.TryParse(args[0], out var station) || !stationSystem.StationInfo.ContainsKey(new StationId(station)))
if (!int.TryParse(args[0], out var stationInt) || !stationSystem.Stations.Contains(new EntityUid(stationInt)))
{
shell.WriteError(Loc.GetString("shell-argument-station-id-invalid", ("index", 1)));
return;
}
var prototypeManager = IoCManager.Resolve<IPrototypeManager>();
var station = new EntityUid(stationInt);
if (!prototypeManager.TryIndex<JobPrototype>(args[1], out var job))
{
@@ -52,6 +49,12 @@ public sealed class AdjustStationJobCommand : IConsoleCommand
return;
}
stationSystem.AdjustJobsAvailableOnStation(new StationId(station), job, amount);
if (amount == -1)
{
stationJobs.MakeJobUnlimited(station, job);
return;
}
stationJobs.TrySetJobSlot(station, job, amount, true);
}
}

View File

@@ -1,15 +1,15 @@
using Content.Server.Station;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
using Content.Shared.Administration;
using Content.Shared.Station;
using Robust.Shared.Console;
using Robust.Shared.GameObjects;
using Robust.Shared.Localization;
namespace Content.Server.Administration.Commands.Station;
[AdminCommand(AdminFlags.Admin)]
public sealed class ListStationJobsCommand : IConsoleCommand
{
[Dependency] private readonly IEntityManager _entityManager = default!;
public string Command => "lsstationjobs";
public string Description => "Lists all jobs on the given station.";
@@ -25,16 +25,18 @@ public sealed class ListStationJobsCommand : IConsoleCommand
}
var stationSystem = EntitySystem.Get<StationSystem>();
var stationJobs = EntitySystem.Get<StationJobsSystem>();
if (!uint.TryParse(args[0], out var station) || !stationSystem.StationInfo.ContainsKey(new StationId(station)))
if (!int.TryParse(args[0], out var station) || !stationSystem.Stations.Contains(new EntityUid(station)))
{
shell.WriteError(Loc.GetString("shell-argument-station-id-invalid", ("index", 1)));
return;
}
foreach (var (job, amount) in stationSystem.StationInfo[new StationId(station)].JobList)
foreach (var (job, amount) in stationJobs.GetJobs(new EntityUid(station)))
{
shell.WriteLine($"{job}: {amount}");
var amountText = amount is null ? "Infinite" : amount.ToString();
shell.WriteLine($"{job}: {amountText}");
}
}
}

View File

@@ -1,4 +1,6 @@
using Content.Server.Station;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
using Content.Shared.Administration;
using Robust.Shared.Console;
using Robust.Shared.GameObjects;
@@ -8,6 +10,8 @@ namespace Content.Server.Administration.Commands.Station;
[AdminCommand(AdminFlags.Admin)]
public sealed class ListStationsCommand : IConsoleCommand
{
[Dependency] private readonly IEntityManager _entityManager = default!;
public string Command => "lsstations";
public string Description => "List all active stations";
@@ -16,9 +20,10 @@ public sealed class ListStationsCommand : IConsoleCommand
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
foreach (var (id, station) in EntitySystem.Get<StationSystem>().StationInfo)
foreach (var station in EntitySystem.Get<StationSystem>().Stations)
{
shell.WriteLine($"{id.Id, -2} | {station.Name} | {station.MapPrototype.ID}");
var name = _entityManager.GetComponent<MetaDataComponent>(station).EntityName;
shell.WriteLine($"{station, -10} | {name}");
}
}
}

View File

@@ -1,9 +1,6 @@
using Content.Server.Station;
using Content.Server.Station.Systems;
using Content.Shared.Administration;
using Content.Shared.Station;
using Robust.Shared.Console;
using Robust.Shared.GameObjects;
using Robust.Shared.Localization;
namespace Content.Server.Administration.Commands.Station;
@@ -26,12 +23,12 @@ public sealed class RenameStationCommand : IConsoleCommand
var stationSystem = EntitySystem.Get<StationSystem>();
if (!uint.TryParse(args[0], out var station) || !stationSystem.StationInfo.ContainsKey(new StationId(station)))
if (!int.TryParse(args[0], out var station) || !stationSystem.Stations.Contains(new EntityUid(station)))
{
shell.WriteError(Loc.GetString("shell-argument-station-id-invalid", ("index", 1)));
return;
}
stationSystem.RenameStation(new StationId(station), args[1]);
stationSystem.RenameStation(new EntityUid(station), args[1]);
}
}

View File

@@ -1,8 +1,7 @@
using Content.Server.Station;
using Content.Server.Station.Systems;
using Content.Shared.Administration;
using Content.Shared.GameTicking;
using Content.Shared.Roles;
using Content.Shared.Station;
using Robust.Server.Player;
using Robust.Shared.Console;
using Robust.Shared.Prototypes;
@@ -39,6 +38,7 @@ namespace Content.Server.GameTicking.Commands
var ticker = EntitySystem.Get<GameTicker>();
var stationSystem = EntitySystem.Get<StationSystem>();
var stationJobs = EntitySystem.Get<StationJobsSystem>();
if (!ticker.PlayersInLobby.ContainsKey(player) || ticker.PlayersInLobby[player] == LobbyPlayerStatus.Observer)
{
@@ -56,23 +56,23 @@ namespace Content.Server.GameTicking.Commands
{
string id = args[0];
if (!uint.TryParse(args[1], out var sid))
if (!int.TryParse(args[1], out var sid))
{
shell.WriteError(Loc.GetString("shell-argument-must-be-number"));
}
var stationId = new StationId(sid);
var station = new EntityUid(sid);
var jobPrototype = _prototypeManager.Index<JobPrototype>(id);
if(!stationSystem.IsJobAvailableOnStation(stationId, jobPrototype))
if(stationJobs.TryGetJobSlot(station, jobPrototype, out var slots) == false || slots == 0)
{
shell.WriteLine($"{jobPrototype.Name} has no available slots.");
return;
}
ticker.MakeJoinGame(player, stationId, id);
ticker.MakeJoinGame(player, station, id);
return;
}
ticker.MakeJoinGame(player, StationId.Invalid);
ticker.MakeJoinGame(player, EntityUid.Invalid);
}
}
}

View File

@@ -38,7 +38,7 @@ namespace Content.Server.GameTicking
_configurationManager.OnValueChanged(CCVars.GameDummyTicker, value => DummyTicker = value, true);
_configurationManager.OnValueChanged(CCVars.GameLobbyDuration, value => LobbyDuration = TimeSpan.FromSeconds(value), true);
_configurationManager.OnValueChanged(CCVars.GameDisallowLateJoins,
value => { DisallowLateJoin = value; UpdateLateJoinStatus(); UpdateJobsAvailable(); }, true);
value => { DisallowLateJoin = value; UpdateLateJoinStatus(); }, true);
_configurationManager.OnValueChanged(CCVars.StationOffset, value => StationOffset = value, true);
_configurationManager.OnValueChanged(CCVars.StationRotation, value => StationRotation = value, true);
_configurationManager.OnValueChanged(CCVars.MaxStationOffset, value => MaxStationOffset = value, true);

View File

@@ -1,196 +0,0 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Shared.GameTicking;
using Content.Shared.Preferences;
using Content.Shared.Roles;
using Content.Shared.Station;
using Robust.Server.Player;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Utility;
namespace Content.Server.GameTicking
{
// This code is responsible for the assigning & picking of jobs.
public sealed partial class GameTicker
{
[ViewVariables]
private readonly List<ManifestEntry> _manifest = new();
[ViewVariables]
private readonly Dictionary<string, int> _spawnedPositions = new();
private Dictionary<IPlayerSession, (string, StationId)> AssignJobs(List<IPlayerSession> availablePlayers,
Dictionary<NetUserId, HumanoidCharacterProfile> profiles)
{
var assigned = new Dictionary<IPlayerSession, (string, StationId)>();
List<(IPlayerSession, List<string>)> GetPlayersJobCandidates(bool heads, JobPriority i)
{
return availablePlayers.Select(player =>
{
var profile = profiles[player.UserId];
var roleBans = _roleBanManager.GetJobBans(player.UserId);
var availableJobs = profile.JobPriorities
.Where(j =>
{
var (jobId, priority) = j;
if (!_prototypeManager.TryIndex(jobId, out JobPrototype? job))
{
// Job doesn't exist, probably old data?
return false;
}
if (job.IsHead != heads)
{
return false;
}
return priority == i;
})
.Where(p => roleBans != null && !roleBans.Contains(p.Key))
.Select(j => j.Key)
.ToList();
return (player, availableJobs);
})
.Where(p => p.availableJobs.Count != 0)
.ToList();
}
void ProcessJobs(bool heads, Dictionary<string, int> availablePositions, StationId id, JobPriority i)
{
var candidates = GetPlayersJobCandidates(heads, i);
foreach (var (candidate, jobs) in candidates)
{
while (jobs.Count != 0)
{
var picked = _robustRandom.Pick(jobs);
var openPositions = availablePositions.GetValueOrDefault(picked, 0);
if (openPositions == 0)
{
jobs.Remove(picked);
continue;
}
availablePositions[picked] -= 1;
assigned.Add(candidate, (picked, id));
break;
}
}
availablePlayers.RemoveAll(a => assigned.ContainsKey(a));
}
// Current strategy is to fill each station one by one.
foreach (var (id, station) in _stationSystem.StationInfo)
{
// Get the ROUND-START job list.
var availablePositions = station.MapPrototype.AvailableJobs.ToDictionary(x => x.Key, x => x.Value[0]);
for (var i = JobPriority.High; i > JobPriority.Never; i--)
{
// Process jobs possible for heads...
ProcessJobs(true, availablePositions, id, i);
// and then jobs that are not heads.
ProcessJobs(false, availablePositions, id, i);
}
}
return assigned;
}
private string? PickBestAvailableJob(IPlayerSession playerSession, HumanoidCharacterProfile profile,
StationId station)
{
if (station == StationId.Invalid)
return null;
var available = _stationSystem.StationInfo[station].JobList;
bool TryPick(JobPriority priority, [NotNullWhen(true)] out string? jobId)
{
var roleBans = _roleBanManager.GetJobBans(playerSession.UserId);
var filtered = profile.JobPriorities
.Where(p => p.Value == priority)
.Where(p => roleBans != null && !roleBans.Contains(p.Key))
.Select(p => p.Key)
.ToList();
while (filtered.Count != 0)
{
jobId = _robustRandom.Pick(filtered);
if (available.GetValueOrDefault(jobId, 0) > 0)
{
return true;
}
filtered.Remove(jobId);
}
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;
}
var overflows = _stationSystem.StationInfo[station].MapPrototype.OverflowJobs.Clone().ToList();
return overflows.Count != 0 ? _robustRandom.Pick(overflows) : null;
}
[Conditional("DEBUG")]
private void InitializeJobController()
{
// Verify that the overflow role exists and has the correct name.
var role = _prototypeManager.Index<JobPrototype>(FallbackOverflowJob);
DebugTools.Assert(role.Name == Loc.GetString(FallbackOverflowJobName),
"Overflow role does not have the correct name!");
}
private void AddSpawnedPosition(string jobId)
{
_spawnedPositions[jobId] = _spawnedPositions.GetValueOrDefault(jobId, 0) + 1;
}
private TickerJobsAvailableEvent GetJobsAvailable()
{
// If late join is disallowed, return no available jobs.
if (DisallowLateJoin)
return new TickerJobsAvailableEvent(new Dictionary<StationId, string>(), new Dictionary<StationId, Dictionary<string, int>>());
var jobs = new Dictionary<StationId, Dictionary<string, int>>();
var stationNames = new Dictionary<StationId, string>();
foreach (var (id, station) in _stationSystem.StationInfo)
{
var list = station.JobList.ToDictionary(x => x.Key, x => x.Value);
jobs.Add(id, list);
stationNames.Add(id, station.Name);
}
return new TickerJobsAvailableEvent(stationNames, jobs);
}
public void UpdateJobsAvailable()
{
RaiseNetworkEvent(GetJobsAvailable(), Filter.Empty().AddPlayers(_playersInLobby.Keys));
}
}
}

View File

@@ -1,16 +1,10 @@
using System;
using Content.Server.Players;
using Content.Server.Roles;
using Content.Server.Station;
using Content.Shared.GameTicking;
using Content.Shared.GameWindow;
using Content.Shared.Preferences;
using Content.Shared.Station;
using JetBrains.Annotations;
using Robust.Server.Player;
using Robust.Shared.Enums;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@@ -112,7 +106,7 @@ namespace Content.Server.GameTicking
async void SpawnWaitPrefs()
{
await _prefsManager.WaitPreferencesLoaded(session);
SpawnPlayer(session, StationId.Invalid);
SpawnPlayer(session, EntityUid.Invalid);
}
async void AddPlayerToDb(Guid id)
@@ -151,7 +145,7 @@ namespace Content.Server.GameTicking
RaiseNetworkEvent(GetStatusMsg(session), client);
RaiseNetworkEvent(GetInfoMsg(), client);
RaiseNetworkEvent(GetPlayerStatus(), client);
RaiseNetworkEvent(GetJobsAvailable(), client);
RaiseLocalEvent(new PlayerJoinedLobbyEvent(session));
}
private void ReqWindowAttentionAll()
@@ -159,4 +153,14 @@ namespace Content.Server.GameTicking
RaiseNetworkEvent(new RequestWindowAttentionEvent());
}
}
public sealed class PlayerJoinedLobbyEvent : EntityEventArgs
{
public readonly IPlayerSession PlayerSession;
public PlayerJoinedLobbyEvent(IPlayerSession playerSession)
{
PlayerSession = playerSession;
}
}
}

View File

@@ -6,13 +6,11 @@ using Content.Server.Ghost;
using Content.Server.Maps;
using Content.Server.Mind;
using Content.Server.Players;
using Content.Server.Station;
using Content.Shared.CCVar;
using Content.Shared.Coordinates;
using Content.Shared.GameTicking;
using Content.Shared.Preferences;
using Content.Shared.Sound;
using Content.Shared.Station;
using JetBrains.Annotations;
using Prometheus;
using Robust.Server.Maps;
@@ -207,7 +205,7 @@ namespace Content.Server.GameTicking
// MapInitialize *before* spawning players, our codebase is too shit to do it afterwards...
_mapManager.DoMapInitialize(DefaultMap);
SpawnPlayers(readyPlayers, origReadyPlayers, profiles, force);
SpawnPlayers(readyPlayers, origReadyPlayers.Select(x => x.UserId), profiles, force);
_roundStartDateTime = DateTime.UtcNow;
RunLevel = GameRunLevel.InRound;
@@ -216,7 +214,6 @@ namespace Content.Server.GameTicking
SendStatusToAll();
ReqWindowAttentionAll();
UpdateLateJoinStatus();
UpdateJobsAvailable();
AnnounceRound();
#if EXCEPTION_TOLERANCE
@@ -428,8 +425,6 @@ namespace Content.Server.GameTicking
// So clients' entity systems can clean up too...
RaiseNetworkEvent(ev, Filter.Broadcast());
_spawnedPositions.Clear();
_manifest.Clear();
DisallowLateJoin = false;
}

View File

@@ -1,25 +1,17 @@
using System.Globalization;
using System.Linq;
using Content.Server.Access.Systems;
using Content.Server.Ghost;
using Content.Server.Ghost.Components;
using Content.Server.Hands.Components;
using Content.Server.Players;
using Content.Server.Roles;
using Content.Server.Spawners.Components;
using Content.Server.Speech.Components;
using Content.Server.Station;
using Content.Shared.Access.Components;
using Content.Shared.Database;
using Content.Shared.GameTicking;
using Content.Shared.Ghost;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Inventory;
using Content.Shared.PDA;
using Content.Shared.Preferences;
using Content.Shared.Roles;
using Content.Shared.Species;
using Content.Shared.Station;
using JetBrains.Annotations;
using Robust.Server.Player;
using Robust.Shared.Map;
using Robust.Shared.Network;
@@ -32,85 +24,35 @@ namespace Content.Server.GameTicking
{
private const string ObserverPrototypeName = "MobObserver";
[Dependency] private readonly IdCardSystem _cardSystem = default!;
[Dependency] private readonly InventorySystem _inventorySystem = default!;
[Dependency] private readonly SharedHandsSystem _handsSystem = default!;
/// <summary>
/// Can't yet be removed because every test ever seems to depend on it. I'll make removing this a different PR.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[ViewVariables(VVAccess.ReadWrite), Obsolete("Due for removal when observer spawning is refactored.")]
private EntityCoordinates _spawnPoint;
// Mainly to avoid allocations.
private readonly List<EntityCoordinates> _possiblePositions = new();
private void SpawnPlayers(List<IPlayerSession> readyPlayers, IPlayerSession[] origReadyPlayers,
private void SpawnPlayers(List<IPlayerSession> readyPlayers, IEnumerable<NetUserId> origReadyPlayers,
Dictionary<NetUserId, HumanoidCharacterProfile> profiles, bool force)
{
// Allow game rules to spawn players by themselves if needed. (For example, nuke ops or wizard)
RaiseLocalEvent(new RulePlayerSpawningEvent(readyPlayers, profiles, force));
var assignedJobs = AssignJobs(readyPlayers, profiles);
var assignedJobs = _stationJobs.AssignJobs(profiles, _stationSystem.Stations.ToList());
AssignOverflowJobs(assignedJobs, origReadyPlayers, profiles);
_stationJobs.AssignOverflowJobs(ref assignedJobs, origReadyPlayers, profiles, _stationSystem.Stations.ToList());
// Spawn everybody in!
foreach (var (player, (job, station)) in assignedJobs)
{
SpawnPlayer(player, profiles[player.UserId], station, job, false);
SpawnPlayer(_playerManager.GetSessionByUserId(player), profiles[player], station, job, false);
}
RefreshLateJoinAllowed();
// Allow rules to add roles to players who have been spawned in. (For example, on-station traitors)
RaiseLocalEvent(new RulePlayerJobsAssignedEvent(assignedJobs.Keys.ToArray(), profiles, force));
RaiseLocalEvent(new RulePlayerJobsAssignedEvent(assignedJobs.Keys.Select(x => _playerManager.GetSessionByUserId(x)).ToArray(), profiles, force));
}
private void AssignOverflowJobs(IDictionary<IPlayerSession, (string, StationId)> assignedJobs,
IPlayerSession[] origReadyPlayers, IReadOnlyDictionary<NetUserId, HumanoidCharacterProfile> profiles)
{
// For players without jobs, give them the overflow job if they have that set...
foreach (var player in origReadyPlayers)
{
if (assignedJobs.ContainsKey(player))
{
continue;
}
var profile = profiles[player.UserId];
if (profile.PreferenceUnavailable != PreferenceUnavailableMode.SpawnAsOverflow)
continue;
// Pick a random station
var stations = _stationSystem.StationInfo.Keys.ToList();
if (stations.Count == 0)
{
assignedJobs.Add(player, (FallbackOverflowJob, StationId.Invalid));
continue;
}
_robustRandom.Shuffle(stations);
foreach (var station in stations)
{
// Pick a random overflow job from that station
var overflows = _stationSystem.StationInfo[station].MapPrototype.OverflowJobs.Clone();
_robustRandom.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], stations[0]));
break;
}
}
}
private void SpawnPlayer(IPlayerSession player, StationId station, string? jobId = null, bool lateJoin = true)
private void SpawnPlayer(IPlayerSession player, EntityUid station, string? jobId = null, bool lateJoin = true)
{
var character = GetPlayerProfile(player);
@@ -118,21 +60,20 @@ namespace Content.Server.GameTicking
if (jobBans == null || (jobId != null && jobBans.Contains(jobId)))
return;
SpawnPlayer(player, character, station, jobId, lateJoin);
UpdateJobsAvailable();
}
private void SpawnPlayer(IPlayerSession player, HumanoidCharacterProfile character, StationId station, string? jobId = null, bool lateJoin = true)
private void SpawnPlayer(IPlayerSession player, HumanoidCharacterProfile character, EntityUid station, string? jobId = null, bool lateJoin = true)
{
// Can't spawn players with a dummy ticker!
if (DummyTicker)
return;
if (station == StationId.Invalid)
if (station == EntityUid.Invalid)
{
var stations = _stationSystem.StationInfo.Keys.ToList();
var stations = _stationSystem.Stations.ToList();
_robustRandom.Shuffle(stations);
if (stations.Count == 0)
station = StationId.Invalid;
station = EntityUid.Invalid;
else
station = stations[0];
}
@@ -155,7 +96,8 @@ namespace Content.Server.GameTicking
}
// Pick best job best on prefs.
jobId ??= PickBestAvailableJob(player, character, station);
jobId ??= _stationJobs.PickBestAvailableJobWithPriority(station, character.JobPriorities, true,
_roleBanManager.GetJobBans(player.UserId));
// If no job available, stay in lobby, or if no lobby spawn as observer
if (jobId is null)
{
@@ -194,7 +136,10 @@ namespace Content.Server.GameTicking
playDefaultSound: false);
}
var mob = SpawnPlayerMob(job, character, station, lateJoin);
var mobMaybe = _stationSpawning.SpawnPlayerCharacterOnStation(station, job, character);
DebugTools.AssertNotNull(mobMaybe);
var mob = mobMaybe!.Value;
newMind.TransferTo(mob);
if (player.UserId == new Guid("{e887eb93-f503-4b65-95b6-2f282c014192}"))
@@ -202,21 +147,12 @@ namespace Content.Server.GameTicking
EntityManager.AddComponent<OwOAccentComponent>(mob);
}
AddManifestEntry(character.Name, jobId);
AddSpawnedPosition(jobId);
EquipIdCard(mob, character.Name, jobPrototype);
foreach (var jobSpecial in jobPrototype.Special)
{
jobSpecial.AfterEquip(mob);
}
_stationSystem.TryAssignJobToStation(station, jobPrototype);
_stationJobs.TryAssignJob(station, jobPrototype);
if (lateJoin)
_adminLogSystem.Add(LogType.LateJoin, LogImpact.Medium, $"Player {player.Name} late joined as {character.Name:characterName} on station {_stationSystem.StationInfo[station].Name:stationName} with {ToPrettyString(mob):entity} as a {job.Name:jobName}.");
_adminLogSystem.Add(LogType.LateJoin, LogImpact.Medium, $"Player {player.Name} late joined as {character.Name:characterName} on station {Name(station):stationName} with {ToPrettyString(mob):entity} as a {job.Name:jobName}.");
else
_adminLogSystem.Add(LogType.RoundStartJoin, LogImpact.Medium, $"Player {player.Name} joined as {character.Name:characterName} on station {_stationSystem.StationInfo[station].Name:stationName} with {ToPrettyString(mob):entity} as a {job.Name:jobName}.");
_adminLogSystem.Add(LogType.RoundStartJoin, LogImpact.Medium, $"Player {player.Name} joined as {character.Name:characterName} on station {Name(station):stationName} with {ToPrettyString(mob):entity} as a {job.Name:jobName}.");
// We raise this event directed to the mob, but also broadcast it so game rules can do something now.
var aev = new PlayerSpawnCompleteEvent(mob, player, jobId, lateJoin, station, character);
@@ -231,10 +167,10 @@ namespace Content.Server.GameTicking
if (LobbyEnabled)
PlayerJoinLobby(player);
else
SpawnPlayer(player, StationId.Invalid);
SpawnPlayer(player, EntityUid.Invalid);
}
public void MakeJoinGame(IPlayerSession player, StationId station, string? jobId = null)
public void MakeJoinGame(IPlayerSession player, EntityUid station, string? jobId = null)
{
if (!_playersInLobby.ContainsKey(player)) return;
@@ -276,28 +212,6 @@ namespace Content.Server.GameTicking
}
#region Mob Spawning Helpers
private EntityUid SpawnPlayerMob(Job job, HumanoidCharacterProfile? profile, StationId station, bool lateJoin = true)
{
var coordinates = lateJoin ? GetLateJoinSpawnPoint(station) : GetJobSpawnPoint(job.Prototype.ID, station);
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)
{
_humanoidAppearanceSystem.UpdateFromProfile(entity, profile);
EntityManager.GetComponent<MetaDataComponent>(entity).EntityName = profile.Name;
}
return entity;
}
private EntityUid SpawnObserverMob()
{
var coordinates = GetObserverSpawnPoint();
@@ -305,108 +219,7 @@ namespace Content.Server.GameTicking
}
#endregion
#region Equip Helpers
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);
}
}
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
private void AddManifestEntry(string characterName, string jobId)
{
_manifest.Add(new ManifestEntry(characterName, jobId));
}
#region Spawn Points
public EntityCoordinates GetJobSpawnPoint(string jobId, StationId station)
{
var location = _spawnPoint;
_possiblePositions.Clear();
foreach (var (point, transform) in EntityManager.EntityQuery<SpawnPointComponent, TransformComponent>(true))
{
var matchingStation =
EntityManager.TryGetComponent<StationComponent>(transform.ParentUid, out var stationComponent) &&
stationComponent.Station == station;
DebugTools.Assert(EntityManager.TryGetComponent<IMapGridComponent>(transform.ParentUid, out _));
if (point.SpawnType == SpawnPointType.Job && point.Job?.ID == jobId && matchingStation)
_possiblePositions.Add(transform.Coordinates);
}
if (_possiblePositions.Count != 0)
location = _robustRandom.Pick(_possiblePositions);
else
location = GetLateJoinSpawnPoint(station); // We need a sane fallback here, so latejoin it is.
return location;
}
public EntityCoordinates GetLateJoinSpawnPoint(StationId station)
{
var location = _spawnPoint;
_possiblePositions.Clear();
foreach (var (point, transform) in EntityManager.EntityQuery<SpawnPointComponent, TransformComponent>(true))
{
var matchingStation =
EntityManager.TryGetComponent<StationComponent>(transform.ParentUid, out var stationComponent) &&
stationComponent.Station == station;
DebugTools.Assert(EntityManager.TryGetComponent<IMapGridComponent>(transform.ParentUid, out _));
if (point.SpawnType == SpawnPointType.LateJoin && matchingStation)
_possiblePositions.Add(transform.Coordinates);
}
if (_possiblePositions.Count != 0)
location = _robustRandom.Pick(_possiblePositions);
return location;
}
public EntityCoordinates GetObserverSpawnPoint()
{
var location = _spawnPoint;
@@ -432,15 +245,16 @@ namespace Content.Server.GameTicking
/// You can use this event to spawn a player off-station on late-join but also at round start.
/// When this event is handled, the GameTicker will not perform its own player-spawning logic.
/// </summary>
[PublicAPI]
public sealed class PlayerBeforeSpawnEvent : HandledEntityEventArgs
{
public IPlayerSession Player { get; }
public HumanoidCharacterProfile Profile { get; }
public string? JobId { get; }
public bool LateJoin { get; }
public StationId Station { get; }
public EntityUid Station { get; }
public PlayerBeforeSpawnEvent(IPlayerSession player, HumanoidCharacterProfile profile, string? jobId, bool lateJoin, StationId station)
public PlayerBeforeSpawnEvent(IPlayerSession player, HumanoidCharacterProfile profile, string? jobId, bool lateJoin, EntityUid station)
{
Player = player;
Profile = profile;
@@ -455,16 +269,17 @@ namespace Content.Server.GameTicking
/// You can use this to handle people late-joining, or to handle people being spawned at round start.
/// Can be used to give random players a role, modify their equipment, etc.
/// </summary>
[PublicAPI]
public sealed class PlayerSpawnCompleteEvent : EntityEventArgs
{
public EntityUid Mob { get; }
public IPlayerSession Player { get; }
public string? JobId { get; }
public bool LateJoin { get; }
public StationId Station { get; }
public EntityUid Station { get; }
public HumanoidCharacterProfile Profile { get; }
public PlayerSpawnCompleteEvent(EntityUid mob, IPlayerSession player, string? jobId, bool lateJoin, StationId station, HumanoidCharacterProfile profile)
public PlayerSpawnCompleteEvent(EntityUid mob, IPlayerSession player, string? jobId, bool lateJoin, EntityUid station, HumanoidCharacterProfile profile)
{
Mob = mob;
Player = player;

View File

@@ -7,10 +7,11 @@ using Content.Server.Ghost;
using Content.Server.Maps;
using Content.Server.PDA;
using Content.Server.Preferences.Managers;
using Content.Server.Station;
using Content.Server.Station.Systems;
using Content.Shared.Chat;
using Content.Shared.Damage;
using Content.Shared.GameTicking;
using Content.Shared.Roles;
using Robust.Server;
using Robust.Server.Maps;
using Robust.Server.ServerStatus;
@@ -53,8 +54,9 @@ namespace Content.Server.GameTicking
InitializeLobbyMusic();
InitializeLobbyBackground();
InitializeGamePreset();
DebugTools.Assert(_prototypeManager.Index<JobPrototype>(FallbackOverflowJob).Name == Loc.GetString(FallbackOverflowJobName),
"Overflow role does not have the correct name!");
InitializeGameRules();
InitializeJobController();
InitializeUpdates();
_initialized = true;
@@ -111,6 +113,8 @@ namespace Content.Server.GameTicking
[Dependency] private readonly IRuntimeLog _runtimeLog = default!;
#endif
[Dependency] private readonly StationSystem _stationSystem = default!;
[Dependency] private readonly StationSpawningSystem _stationSpawning = default!;
[Dependency] private readonly StationJobsSystem _stationJobs = default!;
[Dependency] private readonly AdminLogSystem _adminLogSystem = default!;
[Dependency] private readonly HumanoidAppearanceSystem _humanoidAppearanceSystem = default!;
[Dependency] private readonly PDASystem _pdaSystem = default!;

View File

@@ -3,7 +3,7 @@ using System.Threading;
using Content.Server.Chat.Managers;
using Content.Server.Players;
using Content.Server.Roles;
using Content.Server.Station;
using Content.Server.Station.Components;
using Content.Server.Suspicion;
using Content.Server.Suspicion.Roles;
using Content.Server.Traitor.Uplink;
@@ -14,7 +14,6 @@ using Content.Shared.EntityList;
using Content.Shared.GameTicking;
using Content.Shared.Maps;
using Content.Shared.MobState.Components;
using Content.Shared.Random.Helpers;
using Content.Shared.Roles;
using Content.Shared.Sound;
using Content.Shared.Suspicion;
@@ -222,7 +221,7 @@ public sealed class SuspicionRuleSystem : GameRuleSystem
var susLoot = _prototypeManager.Index<EntityLootTablePrototype>(SuspicionLootTable);
foreach (var (_, mapGrid) in EntityManager.EntityQuery<StationComponent, IMapGridComponent>(true))
foreach (var (_, mapGrid) in EntityManager.EntityQuery<StationMemberComponent, IMapGridComponent>(true))
{
// I'm so sorry.
var tiles = mapGrid.Grid.GetAllTiles().ToArray();

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Server.Chat.Managers;
using Content.Server.Station;
using Content.Shared.CCVar;
using Robust.Server.Maps;
using Robust.Server.Player;
@@ -23,7 +24,7 @@ public sealed class GameMapManager : IGameMapManager
[Dependency] private readonly IChatManager _chatManager = default!;
[ViewVariables]
private readonly Queue<string> _previousMaps = new Queue<string>();
private readonly Queue<string> _previousMaps = new();
[ViewVariables]
private GameMapPrototype _currentMap = default!;
[ViewVariables]
@@ -137,7 +138,7 @@ public sealed class GameMapManager : IGameMapManager
var map = GetSelectedMap();
if (markAsPlayed)
_previousMaps.Enqueue(map.ID);
EnqueueMap(map.ID);
return map;
}
@@ -158,14 +159,6 @@ public sealed class GameMapManager : IGameMapManager
return _prototypeManager.TryIndex(gameMap, out map);
}
public string GenerateMapName(GameMapPrototype gameMap)
{
if (gameMap.NameGenerator is not null && gameMap.MapNameTemplate is not null)
return gameMap.NameGenerator.FormatName(gameMap.MapNameTemplate);
else
return gameMap.MapName;
}
public int GetMapQueuePriority(string gameMapProtoName)
{
var i = 0;

View File

@@ -0,0 +1,35 @@
namespace Content.Server.Maps;
public sealed partial class GameMapPrototype
{
/// <summary>
/// Controls if the map can be used as a fallback if no maps are eligible.
/// </summary>
[DataField("fallback")]
public bool Fallback { get; }
/// <summary>
/// Controls if the map can be voted for.
/// </summary>
[DataField("votable")]
public bool Votable { get; } = true;
/// <summary>
/// Minimum players for the given map.
/// </summary>
[DataField("minPlayers", required: true)]
public uint MinPlayers { get; }
/// <summary>
/// Maximum players for the given map.
/// </summary>
[DataField("maxPlayers")]
public uint MaxPlayers { get; } = uint.MaxValue;
[DataField("conditions")] private readonly List<GameMapCondition> _conditions = new();
/// <summary>
/// The game map conditions that must be fulfilled for this map to be selectable.
/// </summary>
public IReadOnlyList<GameMapCondition> Conditions => _conditions;
}

View File

@@ -1,10 +1,6 @@
using System.Collections.Generic;
using Content.Server.Maps.NameGenerators;
using Content.Shared.Roles;
using Content.Server.Station;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.Manager.Attributes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
using Robust.Shared.Utility;
namespace Content.Server.Maps;
@@ -12,25 +8,17 @@ namespace Content.Server.Maps;
/// <summary>
/// Prototype data for a game map.
/// </summary>
[Prototype("gameMap")]
public sealed class GameMapPrototype : IPrototype
/// <remarks>
/// Forks should not directly edit existing parts of this class.
/// Make a new partial for your fancy new feature, it'll save you time later.
/// </remarks>
[Prototype("gameMap"), PublicAPI]
public sealed partial class GameMapPrototype : IPrototype
{
/// <inheritdoc/>
[IdDataFieldAttribute]
[IdDataField]
public string ID { get; } = default!;
/// <summary>
/// Minimum players for the given map.
/// </summary>
[DataField("minPlayers", required: true)]
public uint MinPlayers { get; }
/// <summary>
/// Maximum players for the given map.
/// </summary>
[DataField("maxPlayers")]
public uint MaxPlayers { get; } = uint.MaxValue;
/// <summary>
/// Name of the map to use in generic messages, like the map vote.
/// </summary>
@@ -38,49 +26,16 @@ public sealed class GameMapPrototype : IPrototype
public string MapName { get; } = default!;
/// <summary>
/// Name of the given map.
/// </summary>
[DataField("mapNameTemplate")]
public string? MapNameTemplate { get; } = default!;
/// <summary>
/// Name generator to use for the map, if any.
/// </summary>
[DataField("nameGenerator")]
public GameMapNameGenerator? NameGenerator { get; } = default!;
/// <summary>
/// Relative directory path to the given map, i.e. `Maps/saltern.yml`
/// Relative directory path to the given map, i.e. `/Maps/saltern.yml`
/// </summary>
[DataField("mapPath", required: true)]
public ResourcePath MapPath { get; } = default!;
/// <summary>
/// Controls if the map can be used as a fallback if no maps are eligible.
/// </summary>
[DataField("fallback")]
public bool Fallback { get; }
[DataField("stations")]
private Dictionary<string, StationConfig> _stations = new();
/// <summary>
/// Controls if the map can be voted for.
/// The stations this map contains. The names should match with the BecomesStation components.
/// </summary>
[DataField("votable")]
public bool Votable { get; } = true;
[DataField("conditions")]
public List<GameMapCondition> Conditions { get; } = new();
/// <summary>
/// Jobs used at round start should the station run out of job slots.
/// Doesn't necessarily mean the station has infinite slots for the given jobs midround!
/// </summary>
[DataField("overflowJobs", required: true, customTypeSerializer:typeof(PrototypeIdListSerializer<JobPrototype>))]
public List<string> OverflowJobs { get; } = default!;
/// <summary>
/// Index of all jobs available on the station, of form
/// jobname: [roundstart, midround]
/// </summary>
[DataField("availableJobs", required: true, customTypeSerializer:typeof(PrototypeIdDictionarySerializer<List<int>, JobPrototype>))]
public Dictionary<string, List<int>> AvailableJobs { get; } = default!;
public IReadOnlyDictionary<string, StationConfig> Stations => _stations;
}

View File

@@ -64,6 +64,4 @@ public interface IGameMapManager
/// <param name="gameMap">name of the map</param>
/// <returns>existence</returns>
bool CheckMapExists(string gameMap);
public string GenerateMapName(GameMapPrototype gameMap);
}

View File

@@ -6,7 +6,7 @@ using Robust.Shared.Serialization.Manager.Attributes;
namespace Content.Server.Maps.NameGenerators;
[UsedImplicitly]
public sealed class NanotrasenNameGenerator : GameMapNameGenerator
public sealed class NanotrasenNameGenerator : StationNameGenerator
{
/// <summary>
/// Where the map comes from. Should be a two or three letter code, for example "VG" for Packedstation.

View File

@@ -3,7 +3,7 @@ using Robust.Shared.Serialization.Manager.Attributes;
namespace Content.Server.Maps.NameGenerators;
[ImplicitDataDefinitionForInheritors]
public abstract class GameMapNameGenerator
public abstract class StationNameGenerator
{
public abstract string FormatName(string input);
}

View File

@@ -0,0 +1,57 @@
using System.Linq;
using Content.Server.GameTicking;
using Content.Server.Spawners.Components;
using Content.Server.Station.Systems;
using Robust.Shared.Random;
namespace Content.Server.Spawners.EntitySystems;
public sealed class SpawnPointSystem : EntitySystem
{
[Dependency] private readonly GameTicker _gameTicker = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly StationSystem _stationSystem = default!;
[Dependency] private readonly StationSpawningSystem _stationSpawning = default!;
public override void Initialize()
{
SubscribeLocalEvent<PlayerSpawningEvent>(OnSpawnPlayer);
}
private void OnSpawnPlayer(PlayerSpawningEvent args)
{
// TODO: Cache all this if it ends up important.
var points = EntityQuery<SpawnPointComponent>().ToList();
_random.Shuffle(points);
foreach (var spawnPoint in points)
{
var xform = Transform(spawnPoint.Owner);
if (args.Station != null && _stationSystem.GetOwningStation(spawnPoint.Owner, xform) != args.Station)
continue;
if (_gameTicker.RunLevel == GameRunLevel.InRound && spawnPoint.SpawnType == SpawnPointType.LateJoin)
{
args.SpawnResult = _stationSpawning.SpawnPlayerMob(xform.Coordinates, args.Job,
args.HumanoidCharacterProfile);
return;
}
else if (_gameTicker.RunLevel != GameRunLevel.InRound && spawnPoint.SpawnType == SpawnPointType.Job && (args.Job == null || spawnPoint.Job?.ID == args.Job.Prototype.ID))
{
args.SpawnResult = _stationSpawning.SpawnPlayerMob(xform.Coordinates, args.Job,
args.HumanoidCharacterProfile);
return;
}
}
// Ok we've still not returned, but we need to put them /somewhere/.
// TODO: Refactor gameticker spawning code so we don't have to do this!
foreach (var spawnPoint in points)
{
var xform = Transform(spawnPoint.Owner);
args.SpawnResult = _stationSpawning.SpawnPlayerMob(xform.Coordinates, args.Job, args.HumanoidCharacterProfile);
return;
}
Logger.ErrorS("spawning", "No spawn points were available!");
}
}

View File

@@ -1,10 +1,6 @@
using Content.Server.GameTicking;
using Robust.Shared.Analyzers;
using Robust.Shared.GameObjects;
using Robust.Shared.Serialization.Manager.Attributes;
using Robust.Shared.ViewVariables;
namespace Content.Server.Station;
namespace Content.Server.Station.Components;
/// <summary>
/// Added to grids saved in maps to designate that they are the 'main station' grid.

View File

@@ -1,20 +1,15 @@
using Content.Server.GameTicking;
using Robust.Shared.Analyzers;
using Robust.Shared.GameObjects;
using Robust.Shared.Serialization.Manager.Attributes;
using Robust.Shared.ViewVariables;
namespace Content.Server.Station;
namespace Content.Server.Station.Components;
/// <summary>
/// Added to grids saved in maps to designate them as 'part of a station' and not main grids. I.e. ancillary
/// shuttles for multi-grid stations.
/// </summary>
[RegisterComponent]
[Friend(typeof(GameTicker))]
[RegisterComponent, Friend(typeof(GameTicker)), Obsolete("Performs the exact same function as BecomesStationComponent.")]
public sealed class PartOfStationComponent : Component
{
[DataField("id", required: true)] // does yamllinter even lint maps for required fields?
[DataField("id", required: true)]
[ViewVariables(VVAccess.ReadWrite)]
public string Id = default!;
}

View File

@@ -1,13 +0,0 @@
using Content.Shared.Station;
using Robust.Shared.Analyzers;
using Robust.Shared.GameObjects;
using Robust.Shared.ViewVariables;
namespace Content.Server.Station;
[RegisterComponent, Friend(typeof(StationSystem))]
public sealed class StationComponent : Component
{
[ViewVariables]
public StationId Station = StationId.Invalid;
}

View File

@@ -0,0 +1,28 @@
using Content.Server.Maps;
using Content.Server.Station.Systems;
using Robust.Shared.Map;
namespace Content.Server.Station.Components;
/// <summary>
/// Stores core information about a station, namely it's config and associated grids.
/// All station entities will have this component.
/// </summary>
[RegisterComponent, Friend(typeof(StationSystem))]
public sealed class StationDataComponent : Component
{
/// <summary>
/// The game map prototype, if any, associated with this station.
/// </summary>
[DataField("stationConfig")]
public StationConfig? StationConfig = null;
/// <summary>
/// List of all grids this station is part of.
/// </summary>
/// <remarks>
/// You should not mutate this yourself, go through StationSystem so the appropriate events get fired.
/// </remarks>
[DataField("grids")]
public readonly HashSet<EntityUid> Grids = new();
}

View File

@@ -0,0 +1,61 @@
using Content.Server.Station.Systems;
using Content.Shared.Roles;
using JetBrains.Annotations;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set;
namespace Content.Server.Station.Components;
/// <summary>
/// Stores information about a station's job selection.
/// </summary>
[RegisterComponent, Friend(typeof(StationJobsSystem)), PublicAPI]
public sealed class StationJobsComponent : Component
{
/// <summary>
/// Total *round-start* jobs at station start.
/// </summary>
[DataField("roundStartTotalJobs")] public int RoundStartTotalJobs;
/// <summary>
/// Total *mid-round* jobs at station start.
/// </summary>
[DataField("midRoundTotalJobs")] public int MidRoundTotalJobs;
/// <summary>
/// Current total jobs.
/// </summary>
[DataField("totalJobs")] public int TotalJobs;
/// <summary>
/// The percentage of jobs remaining.
/// </summary>
/// <remarks>
/// Null if MidRoundTotalJobs is zero. This is a NaN free API.
/// </remarks>
[ViewVariables]
public float? PercentJobsRemaining => MidRoundTotalJobs > 0 ? TotalJobs / (float) MidRoundTotalJobs : null;
/// <summary>
/// The current list of jobs.
/// </summary>
/// <remarks>
/// This should not be mutated or used directly unless you really know what you're doing, go through StationJobsSystem.
/// </remarks>
[DataField("jobList", customTypeSerializer: typeof(PrototypeIdDictionarySerializer<uint?, JobPrototype>))]
public Dictionary<string, uint?> JobList = new();
/// <summary>
/// The round-start list of jobs.
/// </summary>
/// <remarks>
/// This should not be mutated, ever.
/// </remarks>
[DataField("roundStartJobList", customTypeSerializer: typeof(PrototypeIdDictionarySerializer<uint?, JobPrototype>))]
public Dictionary<string, uint?> RoundStartJobList = new();
/// <summary>
/// Overflow jobs that round-start can spawn infinitely many of.
/// </summary>
[DataField("overflowJobs", customTypeSerializer: typeof(PrototypeIdHashSetSerializer<JobPrototype>))] public HashSet<string> OverflowJobs = new();
}

View File

@@ -0,0 +1,16 @@
using Content.Server.Station.Systems;
namespace Content.Server.Station.Components;
/// <summary>
/// Indicates that a grid is a member of the given station.
/// </summary>
[RegisterComponent, Friend(typeof(StationSystem))]
public sealed class StationMemberComponent : Component
{
/// <summary>
/// Station that this grid is a part of.
/// </summary>
[ViewVariables]
public EntityUid Station = EntityUid.Invalid;
}

View File

@@ -0,0 +1,12 @@
using Content.Server.Station.Systems;
using Robust.Shared.Map;
namespace Content.Server.Station.Components;
/// <summary>
/// Controls spawning on the given station, tracking spawners present on it.
/// </summary>
[RegisterComponent, Friend(typeof(StationSpawningSystem))]
public sealed class StationSpawningComponent : Component
{
}

View File

@@ -0,0 +1,28 @@
using Content.Shared.Roles;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
namespace Content.Server.Station;
public sealed partial class StationConfig
{
[DataField("overflowJobs", required: true, customTypeSerializer: typeof(PrototypeIdListSerializer<JobPrototype>))]
private readonly List<string> _overflowJobs = default!;
/// <summary>
/// Jobs used at round start should the station run out of job slots.
/// Doesn't necessarily mean the station has infinite slots for the given jobs mid-round!
/// </summary>
public IReadOnlyList<string> OverflowJobs => _overflowJobs;
[DataField("availableJobs", required: true,
customTypeSerializer: typeof(PrototypeIdDictionarySerializer<List<int?>, JobPrototype>))]
private readonly Dictionary<string, List<int?>> _availableJobs = default!;
/// <summary>
/// Index of all jobs available on the station, of form
/// job name: [round-start, mid-round]
/// </summary>
public IReadOnlyDictionary<string, List<int?>> AvailableJobs => _availableJobs;
}

View File

@@ -0,0 +1,33 @@
using Content.Server.Maps.NameGenerators;
using Content.Shared.Roles;
using JetBrains.Annotations;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
namespace Content.Server.Station;
/// <summary>
/// A config for a station. Specifies name and job slots.
/// This is the only part of stations a downstream should ideally need to modify directly.
/// </summary>
/// <remarks>
/// Forks should not directly edit existing parts of this class.
/// Make a new partial for your fancy new feature, it'll save you time later.
/// </remarks>
[DataDefinition, PublicAPI]
public sealed partial class StationConfig
{
/// <summary>
/// The name template to use for the station.
/// If there's a name generator this should follow it's required format.
/// </summary>
[DataField("mapNameTemplate", required: true)]
public string StationNameTemplate { get; } = default!;
/// <summary>
/// Name generator to use for the station, if any.
/// </summary>
[DataField("nameGenerator")]
public StationNameGenerator? NameGenerator { get; }
}

View File

@@ -1,276 +0,0 @@
using System.Linq;
using Content.Server.Chat.Managers;
using Content.Server.GameTicking;
using Content.Server.Maps;
using Content.Shared.CCVar;
using Content.Shared.Roles;
using Content.Shared.Station;
using Robust.Shared.Configuration;
using Robust.Shared.Map;
using Robust.Shared.Random;
using Robust.Shared.Utility;
namespace Content.Server.Station;
/// <summary>
/// System that manages the jobs available on a station, and maybe other things later.
/// </summary>
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 uint _idCounter = 1;
private Dictionary<StationId, StationInfoData> _stationInfo = new();
/// <summary>
/// List of stations currently loaded.
/// </summary>
public IReadOnlyDictionary<StationId, StationInfoData> StationInfo => _stationInfo;
private bool _randomStationOffset = false;
private bool _randomStationRotation = false;
private float _maxRandomStationOffset = 0.0f;
public override void Initialize()
{
_sawmill = _logManager.GetSawmill("station");
SubscribeLocalEvent<GameRunLevelChangedEvent>(OnRoundEnd);
SubscribeLocalEvent<PreGameMapLoad>(OnPreGameMapLoad);
SubscribeLocalEvent<PostGameMapLoad>(OnPostGameMapLoad);
_configurationManager.OnValueChanged(CCVars.StationOffset, x => _randomStationOffset = x, true);
_configurationManager.OnValueChanged(CCVars.MaxStationOffset, x => _maxRandomStationOffset = x, true);
_configurationManager.OnValueChanged(CCVars.StationRotation, x => _randomStationRotation = x, true);
}
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, StationId>();
// Iterate over all BecomesStation
for (var i = 0; i < ev.Grids.Count; i++)
{
var grid = ev.Grids[i];
// We still setup the grid
if (!TryComp<BecomesStationComponent>(_mapManager.GetGridEuid(grid), out var becomesStation))
continue;
var stationId = InitialSetupStationGrid(grid, ev.GameMap, ev.StationName);
dict.Add(becomesStation.Id, stationId);
}
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];
var stationId = InitialSetupStationGrid(grid, ev.GameMap, ev.StationName);
dict.Add("Station", stationId);
}
// Iterate over all PartOfStation
for (var i = 0; i < ev.Grids.Count; i++)
{
var grid = ev.Grids[i];
var geid = _mapManager.GetGridEuid(grid);
if (!TryComp<PartOfStationComponent>(geid, out var partOfStation))
continue;
if (dict.TryGetValue(partOfStation.Id, out var stationId))
{
AddGridToStation(geid, stationId);
}
else
{
_sawmill.Error($"Grid {grid} ({geid}) specified that it was part of station {partOfStation.Id} which does not exist");
}
}
}
/// <summary>
/// Cleans up station info.
/// </summary>
private void OnRoundEnd(GameRunLevelChangedEvent eventArgs)
{
if (eventArgs.New == GameRunLevel.PreRoundLobby)
_stationInfo = new();
}
public sealed class StationInfoData
{
public string Name;
/// <summary>
/// Job list associated with the game map.
/// </summary>
public readonly GameMapPrototype MapPrototype;
/// <summary>
/// The round job list.
/// </summary>
private readonly Dictionary<string, int> _jobList;
public IReadOnlyDictionary<string, int> JobList => _jobList;
public StationInfoData(string name, GameMapPrototype mapPrototype, Dictionary<string, int> jobList)
{
Name = name;
MapPrototype = mapPrototype;
_jobList = jobList;
}
public bool TryAssignJob(string jobName)
{
if (_jobList.ContainsKey(jobName))
{
switch (_jobList[jobName])
{
case > 0:
_jobList[jobName]--;
return true;
case -1:
return true;
default:
return false;
}
}
else
{
return false;
}
}
public bool AdjustJobAmount(string jobName, int amount)
{
DebugTools.Assert(amount >= -1);
_jobList[jobName] = amount;
return true;
}
}
/// <summary>
/// Creates a new station and attaches it to the given grid.
/// </summary>
/// <param name="mapGrid">grid to attach to</param>
/// <param name="mapPrototype">game map prototype of the station</param>
/// <param name="stationName">name of the station to assign, if not the default</param>
/// <param name="gridComponent">optional grid component of the grid.</param>
/// <returns>The ID of the resulting station</returns>
/// <exception cref="ArgumentException">Thrown when the given entity is not a grid.</exception>
public StationId InitialSetupStationGrid(EntityUid mapGrid, GameMapPrototype mapPrototype, string? stationName = null, IMapGridComponent? gridComponent = null)
{
if (!Resolve(mapGrid, ref gridComponent))
throw new ArgumentException("Tried to initialize a station on a non-grid entity!");
var jobListDict = mapPrototype.AvailableJobs.ToDictionary(x => x.Key, x => x.Value[1]);
var id = AllocateStationInfo();
_stationInfo[id] = new StationInfoData(stationName ?? _gameMapManager.GenerateMapName(mapPrototype), mapPrototype, jobListDict);
var station = EntityManager.AddComponent<StationComponent>(mapGrid);
station.Station = id;
_gameTicker.UpdateJobsAvailable(); // new station means new jobs, tell any lobby-goers.
_sawmill.Info($"Setting up new {mapPrototype.ID} called {_stationInfo[id].Name} on grid {mapGrid}:{gridComponent.GridIndex}");
return id;
}
/// <summary>
/// Adds the given grid to the given station.
/// </summary>
/// <param name="mapGrid">grid to attach</param>
/// <param name="station">station to attach the grid to</param>
/// <param name="gridComponent">optional grid component of the grid.</param>
/// <exception cref="ArgumentException">Thrown when the given entity is not a grid.</exception>
public void AddGridToStation(EntityUid mapGrid, StationId station, IMapGridComponent? gridComponent = null)
{
if (!Resolve(mapGrid, ref gridComponent))
throw new ArgumentException("Tried to initialize a station on a non-grid entity!");
var stationComponent = EntityManager.AddComponent<StationComponent>(mapGrid);
stationComponent.Station = station;
_sawmill.Info( $"Adding grid {mapGrid}:{gridComponent.GridIndex} to station {station} named {_stationInfo[station].Name}");
}
/// <summary>
/// Attempts to assign a job on the given station.
/// Does NOT inform the gameticker that the job roster has changed.
/// </summary>
/// <param name="stationId">station to assign to</param>
/// <param name="job">name of the job</param>
/// <returns>assignment success</returns>
public bool TryAssignJobToStation(StationId stationId, JobPrototype job)
{
if (stationId != StationId.Invalid)
return _stationInfo[stationId].TryAssignJob(job.ID);
else
return false;
}
/// <summary>
/// Checks if the given job is available.
/// </summary>
/// <param name="stationId">station to check</param>
/// <param name="job">name of the job</param>
/// <returns>job availability</returns>
public bool IsJobAvailableOnStation(StationId stationId, JobPrototype job)
{
if (_stationInfo[stationId].JobList.TryGetValue(job.ID, out var amount))
return amount != 0;
return false;
}
private StationId AllocateStationInfo()
{
return new StationId(_idCounter++);
}
public bool AdjustJobsAvailableOnStation(StationId stationId, JobPrototype job, int amount)
{
var ret = _stationInfo[stationId].AdjustJobAmount(job.ID, amount);
_gameTicker.UpdateJobsAvailable();
return ret;
}
public void RenameStation(StationId stationId, string name, bool loud = true)
{
var oldName = _stationInfo[stationId].Name;
_stationInfo[stationId].Name = name;
if (loud)
{
_chatManager.DispatchStationAnnouncement($"The station {oldName} has been renamed to {name}.");
}
// Make sure lobby gets the memo.
_gameTicker.UpdateJobsAvailable();
}
}

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

View File

@@ -1,8 +1,6 @@
using System.Linq;
using Content.Server.Station;
using Content.Shared.Roles;
using Content.Server.Station.Systems;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
namespace Content.Server.StationEvents.Events;
@@ -11,8 +9,7 @@ namespace Content.Server.StationEvents.Events;
public sealed class BureaucraticError : StationEvent
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
public override string? StartAnnouncement =>
public override string StartAnnouncement =>
Loc.GetString("station-event-bureaucratic-error-announcement");
public override string Name => "BureaucraticError";
@@ -27,20 +24,22 @@ public sealed class BureaucraticError : StationEvent
public override void Startup()
{
base.Startup();
var chosenStation = _random.Pick(EntitySystem.Get<StationSystem>().StationInfo.Values.ToList());
var jobList = chosenStation.JobList.Keys.Where(x => !_prototypeManager.Index<JobPrototype>(x).IsHead).ToList();
var stationSystem = EntitySystem.Get<StationSystem>();
var stationJobsSystem = EntitySystem.Get<StationJobsSystem>();
var chosenStation = _random.Pick(stationSystem.Stations.ToList());
var jobList = stationJobsSystem.GetJobs(chosenStation).Keys.ToList();
// Low chance to completely change up the late-join landscape by closing all positions except infinite slots.
// Lower chance than the /tg/ equivalent of this event.
if (_random.Prob(0.25f))
{
var chosenJob = _random.PickAndTake(jobList);
chosenStation.AdjustJobAmount(chosenJob, -1); // INFINITE chaos.
stationJobsSystem.MakeJobUnlimited(chosenStation, chosenJob); // INFINITE chaos.
foreach (var job in jobList)
{
if (chosenStation.JobList[job] == -1)
if (stationJobsSystem.IsJobUnlimited(chosenStation, job))
continue;
chosenStation.AdjustJobAmount(job, 0);
stationJobsSystem.TrySetJobSlot(chosenStation, job, 0);
}
}
else
@@ -49,11 +48,10 @@ public sealed class BureaucraticError : StationEvent
for (var i = 0; i < _random.Next((int)(jobList.Count * 0.20), (int)(jobList.Count * 0.30)); i++)
{
var chosenJob = _random.PickAndTake(jobList);
if (chosenStation.JobList[chosenJob] == -1)
if (stationJobsSystem.IsJobUnlimited(chosenStation, chosenJob))
continue;
var adj = Math.Max(chosenStation.JobList[chosenJob] + _random.Next(-3, 6), 0);
chosenStation.AdjustJobAmount(chosenJob, adj);
stationJobsSystem.TryAdjustJobSlot(chosenStation, chosenJob, _random.Next(-3, 6));
}
}
}

View File

@@ -1,13 +1,8 @@
using Content.Server.Atmos.EntitySystems;
using Content.Shared.Atmos;
using Content.Shared.Sound;
using Content.Shared.Station;
using Robust.Shared.Audio;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Player;
using Robust.Shared.Random;
@@ -60,7 +55,7 @@ namespace Content.Server.StationEvents.Events
// Event variables
private StationId _targetStation;
private EntityUid _targetStation;
private EntityUid _targetGrid;

View File

@@ -1,16 +1,12 @@
using System.Linq;
using Content.Server.Radiation;
using Content.Server.Station;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
using Content.Shared.Coordinates;
using Content.Shared.Sound;
using Content.Shared.Station;
using JetBrains.Annotations;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Map;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Server.StationEvents.Events
{
@@ -20,8 +16,11 @@ namespace Content.Server.StationEvents.Events
// Based on Goonstation style radiation storm with some TG elements (announcer, etc.)
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly IMapManager _mapManager = default!;
[Dependency] private readonly IRobustRandom _robustRandom = default!;
private StationSystem _stationSystem = default!;
public override string Name => "RadiationStorm";
public override string StartAnnouncement => Loc.GetString("station-event-radiation-storm-start-announcement");
protected override string EndAnnouncement => Loc.GetString("station-event-radiation-storm-end-announcement");
@@ -32,7 +31,7 @@ namespace Content.Server.StationEvents.Events
private float _timeUntilPulse;
private const float MinPulseDelay = 0.2f;
private const float MaxPulseDelay = 0.8f;
private StationId _target = StationId.Invalid;
private EntityUid _target = EntityUid.Invalid;
private void ResetTimeUntilPulse()
{
@@ -47,14 +46,17 @@ namespace Content.Server.StationEvents.Events
public override void Startup()
{
_entityManager.EntitySysManager.Resolve(ref _stationSystem);
ResetTimeUntilPulse();
_target = _robustRandom.Pick(_entityManager.EntityQuery<StationComponent>().ToArray()).Station;
base.Startup();
if (_stationSystem.Stations.Count == 0)
{
Running = false;
return;
}
public override void Shutdown()
{
base.Shutdown();
_target = _robustRandom.Pick(_stationSystem.Stations);
base.Startup();
}
public override void Update(float frameTime)
@@ -62,6 +64,11 @@ namespace Content.Server.StationEvents.Events
base.Update(frameTime);
if (!Started || !Running) return;
if (_target.Valid == false)
{
Running = false;
return;
}
_timeUntilPulse -= frameTime;
@@ -69,10 +76,14 @@ namespace Content.Server.StationEvents.Events
{
var mapManager = IoCManager.Resolve<IMapManager>();
// Account for split stations by just randomly picking a piece of it.
var possibleTargets = _entityManager.EntityQuery<StationComponent>()
.Where(x => x.Station == _target).ToArray();
StationComponent tempQualifier = _robustRandom.Pick(possibleTargets);
var stationEnt = (tempQualifier).Owner;
var possibleTargets = _entityManager.GetComponent<StationDataComponent>(_target).Grids;
if (possibleTargets.Count == 0)
{
Running = false;
return;
}
var stationEnt = _robustRandom.Pick(possibleTargets);
if (!_entityManager.TryGetComponent<IMapGridComponent>(stationEnt, out var grid))
return;

View File

@@ -2,15 +2,12 @@ using System.Linq;
using Content.Server.Administration.Logs;
using Content.Server.Atmos.EntitySystems;
using Content.Server.Chat.Managers;
using Content.Server.Station;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
using Content.Shared.Database;
using Content.Shared.Sound;
using Content.Shared.Station;
using Robust.Shared.Audio;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Player;
using Robust.Shared.Random;
@@ -189,20 +186,24 @@ namespace Content.Server.StationEvents.Events
}
public static bool TryFindRandomTile(out Vector2i tile, out StationId targetStation, out EntityUid targetGrid, out EntityCoordinates targetCoords, IRobustRandom? robustRandom = null, IEntityManager? entityManager = null)
public static bool TryFindRandomTile(out Vector2i tile, out EntityUid targetStation, out EntityUid targetGrid, out EntityCoordinates targetCoords, IRobustRandom? robustRandom = null, IEntityManager? entityManager = null, IMapManager? mapManager = null, StationSystem? stationSystem = null)
{
tile = default;
robustRandom ??= IoCManager.Resolve<IRobustRandom>();
entityManager ??= IoCManager.Resolve<IEntityManager>();
IoCManager.Resolve(ref robustRandom, ref entityManager, ref mapManager);
entityManager.EntitySysManager.Resolve(ref stationSystem);
targetCoords = EntityCoordinates.Invalid;
targetStation = robustRandom.Pick(entityManager.EntityQuery<StationComponent>().ToArray()).Station;
var t = targetStation; // thanks C#
var possibleTargets = entityManager.EntityQuery<StationComponent>()
.Where(x => x.Station == t).ToArray();
targetGrid = robustRandom.Pick(possibleTargets).Owner;
targetStation = robustRandom.Pick(stationSystem.Stations);
var possibleTargets = entityManager.GetComponent<StationDataComponent>(targetStation).Grids;
if (possibleTargets.Count == 0)
{
targetGrid = EntityUid.Invalid;
return false;
}
if (!entityManager.TryGetComponent<IMapGridComponent>(targetGrid!, out var gridComp))
targetGrid = robustRandom.Pick(possibleTargets);
if (!entityManager.TryGetComponent<IMapGridComponent>(targetGrid, out var gridComp))
return false;
var grid = gridComp.Grid;

View File

@@ -1,5 +1,4 @@
using Content.Shared.Station;
using Robust.Shared.Network;
using Robust.Shared.Network;
using Robust.Shared.Serialization;
namespace Content.Shared.GameTicking
@@ -8,6 +7,7 @@ namespace Content.Shared.GameTicking
{
// See ideally these would be pulled from the job definition or something.
// But this is easier, and at least it isn't hardcoded.
//TODO: Move these, they really belong in StationJobsSystem or a cvar.
public const string FallbackOverflowJob = "Passenger";
public const string FallbackOverflowJobName = "passenger";
}
@@ -108,10 +108,10 @@ namespace Content.Shared.GameTicking
/// <summary>
/// The Status of the Player in the lobby (ready, observer, ...)
/// </summary>
public Dictionary<StationId, Dictionary<string, int>> JobsAvailableByStation { get; }
public Dictionary<StationId, string> StationNames { get; }
public Dictionary<EntityUid, Dictionary<string, uint?>> JobsAvailableByStation { get; }
public Dictionary<EntityUid, string> StationNames { get; }
public TickerJobsAvailableEvent(Dictionary<StationId, string> stationNames, Dictionary<StationId, Dictionary<string, int>> jobsAvailableByStation)
public TickerJobsAvailableEvent(Dictionary<EntityUid, string> stationNames, Dictionary<EntityUid, Dictionary<string, uint?>> jobsAvailableByStation)
{
StationNames = stationNames;
JobsAvailableByStation = jobsAvailableByStation;

View File

@@ -46,8 +46,8 @@ namespace Content.Shared.Roles
/// Whether this job is a head.
/// The job system will try to pick heads before other jobs on the same priority level.
/// </summary>
[DataField("head")]
public bool IsHead { get; private set; }
[DataField("weight")]
public int Weight { get; private set; }
[DataField("startingGear", customTypeSerializer: typeof(PrototypeIdSerializer<StartingGearPrototype>))]
public string? StartingGear { get; private set; }

View File

@@ -1,10 +0,0 @@
using System;
using Robust.Shared.Serialization;
namespace Content.Shared.Station;
[NetSerializable, Serializable]
public readonly record struct StationId(uint Id)
{
public static StationId Invalid => new(0);
}

View File

@@ -56,4 +56,10 @@ entities:
fixtures: []
bodyType: Dynamic
type: Physics
- uid: 1
type: SpawnPointLatejoin
components:
- parent: 0
pos: 0,0
type: Transform
...

View File

@@ -125,4 +125,10 @@ entities:
type: Gravity
- chunkCollection: {}
type: DecalGrid
- uid: 1
type: SpawnPointLatejoin
components:
- parent: 0
pos: 0,0
type: Transform
...

View File

@@ -14,9 +14,10 @@
- type: entity
name: observer spawn point
id: SpawnPointObserver
parent: SpawnPointJobBase
parent: MarkerBase
components:
- type: Sprite
sprite: Markers/jobs.rsi
layers:
- state: green
- texture: Mobs/Ghosts/ghost_human.rsi/icon.png

View File

@@ -1,12 +1,14 @@
- type: gameMap
id: bagelstation
mapName: 'Bagel Station'
mapPath: /Maps/bagel.yml
minPlayers: 35
stations:
Dart: #TODO: Mapper, fix this name in your map file. (just change the name for every grid with a BecomesStation or PartOfStation component.)
mapNameTemplate: '{0} Bagel Station {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/bagel.yml
minPlayers: 35
overflowJobs:
- Passenger
availableJobs:

View File

@@ -1,12 +1,14 @@
- type: gameMap
id: delta
mapName: 'Delta Station'
mapPath: /Maps/delta.yml
minPlayers: 60
stations:
Station: #TODO: Mapper, add a BecomesStation component to the primary grid of the map.
mapNameTemplate: '{0} Delta Station {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/delta.yml
minPlayers: 60
overflowJobs:
- Passenger
availableJobs:

View File

@@ -1,97 +1,14 @@
- type: gameMap
id: saltern
mapName: 'Saltern'
mapNameTemplate: '{0} Saltern {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/saltern.yml
minPlayers: 0
maxPlayers: 29
fallback: true
overflowJobs:
- Passenger
availableJobs:
CargoTechnician: [ 1, 2 ]
Passenger: [ -1, -1 ]
Bartender: [ 1, 1 ]
Botanist: [ 2, 2 ]
Chef: [ 1, 1 ]
Clown: [ 1, 1 ]
Janitor: [ 1, 1 ]
Mime: [ 1, 1 ]
Captain: [ 1, 1 ]
HeadOfPersonnel: [ 1, 1 ]
ChiefEngineer: [ 1, 1 ]
StationEngineer: [ 2, 3 ]
ChiefMedicalOfficer: [ 1, 1 ]
MedicalDoctor: [ 2, 3 ]
Chemist: [ 1, 1 ]
ResearchDirector: [ 1, 1 ]
Scientist: [ 2, 3 ]
HeadOfSecurity: [ 1, 1 ]
SecurityOfficer: [ 2, 3 ]
Chaplain: [ 1, 1 ]
Librarian: [ 1, 1 ]
Musician: [ 1, 1 ]
Lawyer: [ 1, 1 ]
SalvageSpecialist: [ 1, 2 ]
Quartermaster: [ 1, 1 ]
AtmosphericTechnician: [ 1, 2 ]
- type: gameMap
id: packedstation
mapName: 'Packedstation'
mapNameTemplate: '{0} Packedstation {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: 'VG'
mapPath: /Maps/packedstation.yml
minPlayers: 15
maxPlayers: 55
votable: false
conditions:
- !type:HolidayMapCondition
inverted: true
holidays: [ "Christmas" ]
overflowJobs:
- Passenger
availableJobs:
CargoTechnician: [ 2, 3 ]
Passenger: [ -1, -1 ]
Bartender: [ 1, 1 ]
Botanist: [ 2, 2 ]
Chef: [ 1, 1 ]
Clown: [ 1, 1 ]
Janitor: [ 1, 1 ]
Mime: [ 1, 1 ]
Captain: [ 1, 1 ]
HeadOfPersonnel: [ 1, 1 ]
ChiefEngineer: [ 1, 1 ]
StationEngineer: [ 4, 6 ]
ChiefMedicalOfficer: [ 1, 1 ]
MedicalDoctor: [ 3, 4 ]
Chemist: [ 2, 2 ]
ResearchDirector: [ 1, 1 ]
Scientist: [ 3, 4 ]
HeadOfSecurity: [ 1, 1 ]
SecurityOfficer: [ 2, 3 ]
Chaplain: [ 1, 1 ]
Warden: [ 1, 1 ]
Librarian: [ 1, 1 ]
Lawyer: [ 1, 2 ]
Musician: [1, 1]
AtmosphericTechnician: [ 1, 2 ]
- type: gameMap
id: NSSPillar
mapName: 'NSS Pillar'
mapPath: /Maps/nss_pillar.yml
minPlayers: 40
stations:
Pillar:
mapNameTemplate: '{0} NSSPillar {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/nss_pillar.yml
minPlayers: 40
overflowJobs:
- Passenger
availableJobs:
@@ -126,14 +43,16 @@
- type: gameMap
id: ssreach
mapName: 'Reach'
mapNameTemplate: '{0} Reach {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/ssreach.yml
minPlayers: 0
maxPlayers: 15
votable: false
stations:
Reach:
mapNameTemplate: '{0} Reach {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
overflowJobs:
- Passenger
availableJobs:
@@ -156,13 +75,15 @@
- type: gameMap
id: dart
mapName: 'Dart'
mapPath: /Maps/dart.yml
minPlayers: 0
votable: false
stations:
Station: #TODO: Mapper, add a BecomesStation component to the primary grid of the map.
mapNameTemplate: '{0} Dart {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/dart.yml
minPlayers: 0
votable: false
overflowJobs: []
availableJobs:
Captain: [ 1, 1 ]
@@ -170,13 +91,15 @@
- type: gameMap
id: moonrise
mapName: 'Moonrise ERC'
mapPath: /Maps/moonrise.yml
minPlayers: 0
votable: false
stations:
Station:
mapNameTemplate: '{0} Moonrise {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: 'VG'
mapPath: /Maps/moonrise.yml
minPlayers: 0
votable: false
overflowJobs: []
availableJobs:
Captain: [ 1, 1 ]

View File

@@ -1,36 +1,36 @@
- type: gameMap
id: packedstationxmas
mapName: 'Packedmasstation'
mapNameTemplate: '{0} Packedmasstation {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: 'VG'
mapPath: /Maps/packedstationxmas.yml
minPlayers: 15
conditions:
- !type:HolidayMapCondition
holidays: [ "Christmas" ]
overflowJobs:
- Passenger
availableJobs:
CargoTechnician: [ 2, 3 ]
Passenger: [ -1, -1 ]
Bartender: [ 1, 1 ]
Botanist: [ 2, 2 ]
Chef: [ 1, 1 ]
Clown: [ 1, 1 ]
Janitor: [ 1, 1 ]
Mime: [ 1, 1 ]
Captain: [ 1, 1 ]
HeadOfPersonnel: [ 1, 1 ]
ChiefEngineer: [ 1, 1 ]
StationEngineer: [ 4, 6 ]
ChiefMedicalOfficer: [ 1, 1 ]
MedicalDoctor: [ 3, 4 ]
Chemist: [ 2, 2 ]
ResearchDirector: [ 1, 1 ]
Scientist: [ 3, 4 ]
HeadOfSecurity: [ 1, 1 ]
SecurityOfficer: [ 2, 3 ]
Chaplain: [ 1, 1 ]
Warden: [ 1, 1 ]
#- type: gameMap
# id: packedstationxmas
# mapName: 'Packedmasstation'
# mapNameTemplate: '{0} Packedmasstation {1}'
# nameGenerator:
# !type:NanotrasenNameGenerator
# prefixCreator: 'VG'
# mapPath: /Maps/packedstationxmas.yml
# minPlayers: 15
# conditions:
# - !type:HolidayMapCondition
# holidays: [ "Christmas" ]
# overflowJobs:
# - Passenger
# availableJobs:
# CargoTechnician: [ 2, 3 ]
# Passenger: [ -1, -1 ]
# Bartender: [ 1, 1 ]
# Botanist: [ 2, 2 ]
# Chef: [ 1, 1 ]
# Clown: [ 1, 1 ]
# Janitor: [ 1, 1 ]
# Mime: [ 1, 1 ]
# Captain: [ 1, 1 ]
# HeadOfPersonnel: [ 1, 1 ]
# ChiefEngineer: [ 1, 1 ]
# StationEngineer: [ 4, 6 ]
# ChiefMedicalOfficer: [ 1, 1 ]
# MedicalDoctor: [ 3, 4 ]
# Chemist: [ 2, 2 ]
# ResearchDirector: [ 1, 1 ]
# Scientist: [ 3, 4 ]
# HeadOfSecurity: [ 1, 1 ]
# SecurityOfficer: [ 2, 3 ]
# Chaplain: [ 1, 1 ]
# Warden: [ 1, 1 ]

View File

@@ -1,13 +1,15 @@
- type: gameMap
id: marathonstation
mapName: 'Marathon Station'
mapPath: /Maps/marathon.yml
minPlayers: 35
maxPlayers: 70
stations:
Station: #TODO: Mapper, add a BecomesStation component to the primary grid of the map.
mapNameTemplate: '{0} Marathon Station {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/marathon.yml
minPlayers: 35
maxPlayers: 70
overflowJobs:
- Passenger
availableJobs:
@@ -22,7 +24,7 @@
Captain: [ 1, 1 ]
HeadOfPersonnel: [ 1, 1 ]
ChiefEngineer: [ 1, 1 ]
StationEngineer: [ 4, 4 ]
StationEngineer: [ 4, 6 ]
ChiefMedicalOfficer: [ 1, 1 ]
MedicalDoctor: [ 3, 4 ]
Chemist: [ 2, 3 ]

View File

@@ -1,13 +1,15 @@
- type: gameMap
id: moosestation
mapName: 'Moose Station'
mapPath: /Maps/moose.yml
minPlayers: 0
maxPlayers: 35
stations:
Station: #TODO: Mapper, add a BecomesStation component to the primary grid of the map.
mapNameTemplate: '{0} Moose Station {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/moose.yml
minPlayers: 0
maxPlayers: 35
overflowJobs:
- Passenger
availableJobs:

View File

@@ -0,0 +1,40 @@
- type: gameMap
id: packedstation
mapName: 'Packedstation'
mapPath: /Maps/packedstation.yml
minPlayers: 15
maxPlayers: 55
votable: false
stations:
Packed:
mapNameTemplate: '{0} Packedstation {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: 'VG'
overflowJobs:
- Passenger
availableJobs:
CargoTechnician: [ 2, 3 ]
Passenger: [ -1, -1 ]
Bartender: [ 1, 1 ]
Botanist: [ 2, 2 ]
Chef: [ 1, 1 ]
Clown: [ 1, 1 ]
Janitor: [ 1, 1 ]
Mime: [ 1, 1 ]
Captain: [ 1, 1 ]
HeadOfPersonnel: [ 1, 1 ]
ChiefEngineer: [ 1, 1 ]
StationEngineer: [ 4, 6 ]
ChiefMedicalOfficer: [ 1, 1 ]
MedicalDoctor: [ 3, 4 ]
Chemist: [ 2, 2 ]
ResearchDirector: [ 1, 1 ]
Scientist: [ 3, 4 ]
HeadOfSecurity: [ 1, 1 ]
SecurityOfficer: [ 2, 3 ]
Chaplain: [ 1, 1 ]
Warden: [ 1, 1 ]
Librarian: [ 1, 1 ]
Lawyer: [ 1, 2 ]
Musician: [1, 1]

View File

@@ -1,18 +1,20 @@
- type: gameMap
id: saltern
mapName: 'Saltern'
mapNameTemplate: '{0} Saltern {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/saltern.yml
minPlayers: 0
maxPlayers: 29
fallback: true
stations:
Saltern:
mapNameTemplate: '{0} Saltern {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
overflowJobs:
- Passenger
availableJobs:
CargoTechnician: [ 2, 2 ]
CargoTechnician: [ 1, 2 ]
Passenger: [ -1, -1 ]
Bartender: [ 1, 1 ]
Botanist: [ 2, 2 ]
@@ -23,21 +25,20 @@
Captain: [ 1, 1 ]
HeadOfPersonnel: [ 1, 1 ]
ChiefEngineer: [ 1, 1 ]
StationEngineer: [ 3, 3 ]
StationEngineer: [ 2, 3 ]
ChiefMedicalOfficer: [ 1, 1 ]
MedicalDoctor: [ 2, 3 ]
Chemist: [ 1, 1 ]
ResearchDirector: [ 1, 1 ]
Scientist: [ 2, 3 ]
HeadOfSecurity: [ 1, 1 ]
SecurityOfficer: [ 3, 3 ]
SecurityOfficer: [ 2, 3 ]
Chaplain: [ 1, 1 ]
Librarian: [ 1, 1 ]
Musician: [ 1, 1 ]
Lawyer: [ 1, 1 ]
SalvageSpecialist: [ 2, 2 ]
SalvageSpecialist: [ 1, 2 ]
Quartermaster: [ 1, 1 ]
AtmosphericTechnician: [ 2, 2 ]
TechnicalAssistant: [ 2, 2 ]
MedicalIntern: [ 2, 2 ]
ServiceWorker: [ 2, 2 ]

View File

@@ -1,12 +1,14 @@
- type: gameMap
id: splitstation
mapName: 'Split Station'
mapPath: /Maps/splitstation.yml
minPlayers: 40
stations:
Dart: #TODO: Mapper, fix this name in your map file. (just change the name for every grid with a BecomesStation or PartOfStation component.)
mapNameTemplate: '{0} Split Station {1}'
nameGenerator:
!type:NanotrasenNameGenerator
prefixCreator: '14'
mapPath: /Maps/splitstation.yml
minPlayers: 60
overflowJobs:
- Passenger
availableJobs:

View File

@@ -4,8 +4,10 @@
mapPath: /Maps/Test/empty.yml
minPlayers: 0
votable: false
stations:
Station: #TODO: Add a BecomesStation to empty.yml
mapNameTemplate: "Empty"
overflowJobs:
- Passenger
availableJobs:
Passenger: [ -1, -1 ]

View File

@@ -1,7 +1,7 @@
- type: job
id: Quartermaster
name: "quartermaster"
head: true
weight: 10
startingGear: QuartermasterGear
departments:
- Cargo

View File

@@ -1,7 +1,7 @@
- type: job
id: Captain
name: "captain"
head: true
weight: 20
startingGear: CaptainGear
departments:
- Command

View File

@@ -1,7 +1,7 @@
- type: job
id: HeadOfPersonnel
name: "head of personnel"
head: true
weight: 20
startingGear: HoPGear
departments:
- Command

View File

@@ -1,7 +1,7 @@
- type: job
id: ChiefEngineer
name: "chief engineer"
head: true
weight: 10
startingGear: ChiefEngineerGear
departments:
- Command

View File

@@ -3,7 +3,7 @@
- type: job
id: ChiefMedicalOfficer
name: "chief medical officer"
head: true
weight: 10
startingGear: CMOGear
departments:
- Command

View File

@@ -1,7 +1,7 @@
- type: job
id: ResearchDirector
name: "research director"
head: true
weight: 10
startingGear: ResearchDirectorGear
departments:
- Command

View File

@@ -1,7 +1,7 @@
- type: job
id: HeadOfSecurity
name: "head of security"
head: true
weight: 10
startingGear: HoSGear
departments:
- Command

View File

@@ -201,6 +201,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Computus/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Constructible/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Cooldowns/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Dentification/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Diethylamine/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Drainable/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=euid/@EntryIndexedValue">True</s:Boolean>
@@ -305,5 +306,6 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Wirecutter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Xeno/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Xenoarchaeology/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xform/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=yamls/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Zumos/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>