diff --git a/Content.Client/GameTicking/Managers/ClientGameTicker.cs b/Content.Client/GameTicking/Managers/ClientGameTicker.cs index 309db2eb4e..fcf5ae91a4 100644 --- a/Content.Client/GameTicking/Managers/ClientGameTicker.cs +++ b/Content.Client/GameTicking/Managers/ClientGameTicker.cs @@ -4,10 +4,12 @@ using Content.Client.Lobby; using Content.Client.RoundEnd; using Content.Shared.GameTicking; using Content.Shared.GameWindow; +using Content.Shared.Roles; using JetBrains.Annotations; using Robust.Client.Graphics; using Robust.Client.State; using Robust.Client.UserInterface; +using Robust.Shared.Prototypes; namespace Content.Client.GameTicking.Managers { @@ -17,10 +19,9 @@ namespace Content.Client.GameTicking.Managers [Dependency] private readonly IStateManager _stateManager = default!; [Dependency] private readonly IClientAdminManager _admin = default!; [Dependency] private readonly IClyde _clyde = default!; - [Dependency] private readonly SharedMapSystem _map = default!; [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; - private Dictionary> _jobsAvailable = new(); + private Dictionary, int?>> _jobsAvailable = new(); private Dictionary _stationNames = new(); [ViewVariables] public bool AreWeReady { get; private set; } @@ -32,13 +33,13 @@ namespace Content.Client.GameTicking.Managers [ViewVariables] public TimeSpan StartTime { get; private set; } [ViewVariables] public new bool Paused { get; private set; } - [ViewVariables] public IReadOnlyDictionary> JobsAvailable => _jobsAvailable; + [ViewVariables] public IReadOnlyDictionary, int?>> JobsAvailable => _jobsAvailable; [ViewVariables] public IReadOnlyDictionary StationNames => _stationNames; public event Action? InfoBlobUpdated; public event Action? LobbyStatusUpdated; public event Action? LobbyLateJoinStatusUpdated; - public event Action>>? LobbyJobsAvailableUpdated; + public event Action, int?>>>? LobbyJobsAvailableUpdated; public override void Initialize() { @@ -69,7 +70,7 @@ namespace Content.Client.GameTicking.Managers // reading the console. E.g., logs like this one could leak the nuke station/grid: // > Grid NT-Arrivals 1101 (122/n25896) changed parent. Old parent: map 10 (121/n25895). New parent: FTL (123/n26470) #if !DEBUG - _map.Log.Level = _admin.IsAdmin() ? LogLevel.Info : LogLevel.Warning; + EntityManager.System().Log.Level = _admin.IsAdmin() ? LogLevel.Info : LogLevel.Warning; #endif } diff --git a/Content.Client/LateJoin/LateJoinGui.cs b/Content.Client/LateJoin/LateJoinGui.cs index 252aa9aafa..62a06629f2 100644 --- a/Content.Client/LateJoin/LateJoinGui.cs +++ b/Content.Client/LateJoin/LateJoinGui.cs @@ -290,7 +290,7 @@ namespace Content.Client.LateJoin } } - private void JobsAvailableUpdated(IReadOnlyDictionary> updatedJobs) + private void JobsAvailableUpdated(IReadOnlyDictionary, int?>> updatedJobs) { foreach (var stationEntries in updatedJobs) { @@ -337,10 +337,10 @@ namespace Content.Client.LateJoin public Label JobLabel { get; } public string JobId { get; } public string JobLocalisedName { get; } - public uint? Amount { get; private set; } + public int? Amount { get; private set; } private bool _initialised = false; - public JobButton(Label jobLabel, string jobId, string jobLocalisedName, uint? amount) + public JobButton(Label jobLabel, ProtoId jobId, string jobLocalisedName, int? amount) { JobLabel = jobLabel; JobId = jobId; @@ -350,7 +350,7 @@ namespace Content.Client.LateJoin _initialised = true; } - public void RefreshLabel(uint? amount) + public void RefreshLabel(int? amount) { if (Amount == amount && _initialised) { diff --git a/Content.Client/Lobby/LobbyUIController.cs b/Content.Client/Lobby/LobbyUIController.cs index f6a3eed962..05b98606ab 100644 --- a/Content.Client/Lobby/LobbyUIController.cs +++ b/Content.Client/Lobby/LobbyUIController.cs @@ -302,7 +302,7 @@ public sealed class LobbyUIController : UIController, IOnStateEntered p.Value == JobPriority.High).Key; // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract (what is resharper smoking?) - return _prototypeManager.Index(highPriorityJob ?? SharedGameTicker.FallbackOverflowJob); + return _prototypeManager.Index(highPriorityJob.Id ?? SharedGameTicker.FallbackOverflowJob); } public void GiveDummyLoadout(EntityUid uid, RoleLoadout? roleLoadout) diff --git a/Content.Client/Lobby/UI/CharacterPickerButton.xaml.cs b/Content.Client/Lobby/UI/CharacterPickerButton.xaml.cs index 2ad8de7445..7efd1c594f 100644 --- a/Content.Client/Lobby/UI/CharacterPickerButton.xaml.cs +++ b/Content.Client/Lobby/UI/CharacterPickerButton.xaml.cs @@ -53,9 +53,9 @@ public sealed partial class CharacterPickerButton : ContainerButton .LoadProfileEntity(humanoid, null, true); var highPriorityJob = humanoid.JobPriorities.SingleOrDefault(p => p.Value == JobPriority.High).Key; - if (highPriorityJob != null) + if (highPriorityJob != default) { - var jobName = prototypeManager.Index(highPriorityJob).LocalizedName; + var jobName = prototypeManager.Index(highPriorityJob).LocalizedName; description = $"{description}\n{jobName}"; } } diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs index 1e8306a02c..588cf0d80e 100644 --- a/Content.IntegrationTests/Pair/TestPair.Helpers.cs +++ b/Content.IntegrationTests/Pair/TestPair.Helpers.cs @@ -8,7 +8,6 @@ using Content.Shared.Roles; using Robust.Shared.GameObjects; using Robust.Shared.Map; using Robust.Shared.Network; -using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.UnitTesting; @@ -135,32 +134,73 @@ public sealed partial class TestPair } /// - /// Helper method for enabling or disabling a antag role + /// Set a user's antag preferences. Modified preferences are automatically reset at the end of the test. /// - public async Task SetAntagPref(ProtoId id, bool value) + public async Task SetAntagPreference(ProtoId id, bool value, NetUserId? user = null) { - await SetAntagPref(Client.User!.Value, id, value); + user ??= Client.User!.Value; + if (user is not {} userId) + return; + + var prefMan = Server.ResolveDependency(); + var prefs = prefMan.GetPreferences(userId); + + // Automatic preference resetting only resets slot 0. + Assert.That(prefs.SelectedCharacterIndex, Is.EqualTo(0)); + + var profile = (HumanoidCharacterProfile) prefs.Characters[0]; + var newProfile = profile.WithAntagPreference(id, value); + _modifiedProfiles.Add(userId); + await Server.WaitPost(() => prefMan.SetProfile(userId, 0, newProfile).Wait()); } - public async Task SetAntagPref(NetUserId user, ProtoId id, bool value) + /// + /// Set a user's job preferences. Modified preferences are automatically reset at the end of the test. + /// + public async Task SetJobPriority(ProtoId id, JobPriority value, NetUserId? user = null) { + user ??= Client.User!.Value; + if (user is { } userId) + await SetJobPriorities(userId, (id, value)); + } + + /// + public async Task SetJobPriorities(params (ProtoId, JobPriority)[] priorities) + => await SetJobPriorities(Client.User!.Value, priorities); + + /// + public async Task SetJobPriorities(NetUserId user, params (ProtoId, JobPriority)[] priorities) + { + var highCount = priorities.Count(x => x.Item2 == JobPriority.High); + Assert.That(highCount, Is.LessThanOrEqualTo(1), "Cannot have more than one high priority job"); + var prefMan = Server.ResolveDependency(); - var prefs = prefMan.GetPreferences(user); - // what even is the point of ICharacterProfile if we always cast it to HumanoidCharacterProfile to make it usable? - var profile = (HumanoidCharacterProfile) prefs.SelectedCharacter; + var profile = (HumanoidCharacterProfile) prefs.Characters[0]; + var dictionary = new Dictionary, JobPriority>(profile.JobPriorities); - Assert.That(profile.AntagPreferences.Contains(id), Is.EqualTo(!value)); - var newProfile = profile.WithAntagPreference(id, value); + // Automatic preference resetting only resets slot 0. + Assert.That(prefs.SelectedCharacterIndex, Is.EqualTo(0)); - await Server.WaitPost(() => + if (highCount != 0) { - prefMan.SetProfile(user, prefs.SelectedCharacterIndex, newProfile).Wait(); - }); + foreach (var (key, priority) in dictionary) + { + if (priority == JobPriority.High) + dictionary[key] = JobPriority.Medium; + } + } - // And why the fuck does it always create a new preference and profile object instead of just reusing them? - var newPrefs = prefMan.GetPreferences(user); - var newProf = (HumanoidCharacterProfile) newPrefs.SelectedCharacter; - Assert.That(newProf.AntagPreferences.Contains(id), Is.EqualTo(value)); + foreach (var (job, priority) in priorities) + { + if (priority == JobPriority.Never) + dictionary.Remove(job); + else + dictionary[job] = priority; + } + + var newProfile = profile.WithJobPriorities(dictionary); + _modifiedProfiles.Add(user); + await Server.WaitPost(() => prefMan.SetProfile(user, 0, newProfile).Wait()); } } diff --git a/Content.IntegrationTests/Pair/TestPair.Recycle.cs b/Content.IntegrationTests/Pair/TestPair.Recycle.cs index 4cae4affc4..89a9eb6463 100644 --- a/Content.IntegrationTests/Pair/TestPair.Recycle.cs +++ b/Content.IntegrationTests/Pair/TestPair.Recycle.cs @@ -2,10 +2,12 @@ using System.IO; using System.Linq; using Content.Server.GameTicking; +using Content.Server.Preferences.Managers; using Content.Shared.CCVar; using Content.Shared.GameTicking; using Content.Shared.Mind; using Content.Shared.Mind.Components; +using Content.Shared.Preferences; using Robust.Client; using Robust.Server.Player; using Robust.Shared.Exceptions; @@ -34,6 +36,9 @@ public sealed partial class TestPair : IAsyncDisposable private async Task OnCleanDispose() { + await Server.WaitIdleAsync(); + await Client.WaitIdleAsync(); + await ResetModifiedPreferences(); await Server.RemoveAllDummySessions(); if (TestMap != null) @@ -81,6 +86,16 @@ public sealed partial class TestPair : IAsyncDisposable await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool"); } + private async Task ResetModifiedPreferences() + { + var prefMan = Server.ResolveDependency(); + foreach (var user in _modifiedProfiles) + { + await Server.WaitPost(() => prefMan.SetProfile(user, 0, new HumanoidCharacterProfile()).Wait()); + } + _modifiedProfiles.Clear(); + } + public async ValueTask CleanReturnAsync() { if (State != PairState.InUse) diff --git a/Content.IntegrationTests/Pair/TestPair.cs b/Content.IntegrationTests/Pair/TestPair.cs index 0b18c38239..0b681dcde1 100644 --- a/Content.IntegrationTests/Pair/TestPair.cs +++ b/Content.IntegrationTests/Pair/TestPair.cs @@ -26,6 +26,8 @@ public sealed partial class TestPair public readonly List TestHistory = new(); public PoolSettings Settings = default!; public TestMapData? TestMap; + private List _modifiedProfiles = new(); + public RobustIntegrationTest.ServerIntegrationInstance Server { get; private set; } = default!; public RobustIntegrationTest.ClientIntegrationInstance Client { get; private set; } = default!; @@ -37,9 +39,7 @@ public sealed partial class TestPair client = Client; } - public ICommonSession? Player => Client.User == null - ? null - : Server.PlayerMan.SessionsDict.GetValueOrDefault(Client.User.Value); + public ICommonSession? Player => Server.PlayerMan.SessionsDict.GetValueOrDefault(Client.User!.Value); public ContentPlayerData? PlayerData => Player?.Data.ContentData(); diff --git a/Content.IntegrationTests/PoolManager.Cvars.cs b/Content.IntegrationTests/PoolManager.Cvars.cs index 5acd9d502c..bcd48f8238 100644 --- a/Content.IntegrationTests/PoolManager.Cvars.cs +++ b/Content.IntegrationTests/PoolManager.Cvars.cs @@ -28,6 +28,7 @@ public static partial class PoolManager (CCVars.EmergencyShuttleEnabled.Name, "false"), (CCVars.ProcgenPreload.Name, "false"), (CCVars.WorldgenEnabled.Name, "false"), + (CCVars.GatewayGeneratorEnabled.Name, "false"), (CVars.ReplayClientRecordingEnabled.Name, "false"), (CVars.ReplayServerRecordingEnabled.Name, "false"), (CCVars.GameDummyTicker.Name, "true"), diff --git a/Content.IntegrationTests/Tests/EntityTest.cs b/Content.IntegrationTests/Tests/EntityTest.cs index 926374cf05..1fc739fb0c 100644 --- a/Content.IntegrationTests/Tests/EntityTest.cs +++ b/Content.IntegrationTests/Tests/EntityTest.cs @@ -340,6 +340,7 @@ namespace Content.IntegrationTests.Tests "MapGrid", "Broadphase", "StationData", // errors when removed mid-round + "StationJobs", "Actor", // We aren't testing actor components, those need their player session set. "BlobFloorPlanBuilder", // Implodes if unconfigured. "DebrisFeaturePlacerController", // Above. diff --git a/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs index 662ea3b974..1bea33a82b 100644 --- a/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs +++ b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs @@ -52,7 +52,7 @@ public sealed class AntagPreferenceTest Assert.That(pool.Count, Is.EqualTo(0)); // Opt into the traitor role. - await pair.SetAntagPref("Traitor", true); + await pair.SetAntagPreference("Traitor", true); Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); @@ -63,7 +63,7 @@ public sealed class AntagPreferenceTest Assert.That(sessions.Count, Is.EqualTo(1)); // opt back out - await pair.SetAntagPref("Traitor", false); + await pair.SetAntagPreference("Traitor", false); Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); diff --git a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs index f56baba342..fc50d0bd33 100644 --- a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs +++ b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs @@ -62,8 +62,8 @@ public sealed class NukeOpsTest await pair.RunTicksSync(5); // Opt into the nukies role. - await pair.SetAntagPref("NukeopsCommander", true); - await pair.SetAntagPref(dummies[1].UserId, "NukeopsMedic", true); + await pair.SetAntagPreference("NukeopsCommander", true); + await pair.SetAntagPreference( "NukeopsMedic", true, dummies[1].UserId); // Initially, the players have no attached entities Assert.That(pair.Player?.AttachedEntity, Is.Null); @@ -236,8 +236,6 @@ public sealed class NukeOpsTest } ticker.SetGamePreset((GamePresetPrototype?)null); - await pair.SetAntagPref("NukeopsCommander", false); - await pair.SetAntagPref(dummies[1].UserId, "NukeopsMedic", false); await pair.CleanReturnAsync(); } } diff --git a/Content.IntegrationTests/Tests/PostMapInitTest.cs b/Content.IntegrationTests/Tests/PostMapInitTest.cs index 17c5e199a7..2ae4389841 100644 --- a/Content.IntegrationTests/Tests/PostMapInitTest.cs +++ b/Content.IntegrationTests/Tests/PostMapInitTest.cs @@ -245,22 +245,15 @@ namespace Content.IntegrationTests.Tests // Test all availableJobs have spawnPoints // This is done inside gamemap test because loading the map takes ages and we already have it. - var jobList = entManager.GetComponent(station).RoundStartJobList - .Where(x => x.Value != 0) - .Select(x => x.Key); - var spawnPoints = entManager.EntityQuery() - .Where(spawnpoint => spawnpoint.SpawnType == SpawnPointType.Job) - .Select(spawnpoint => spawnpoint.Job.ID) - .Distinct(); - List missingSpawnPoints = new(); - foreach (var spawnpoint in jobList.Except(spawnPoints)) - { - if (protoManager.Index(spawnpoint).SetPreference) - missingSpawnPoints.Add(spawnpoint); - } + var comp = entManager.GetComponent(station); + var jobs = new HashSet>(comp.SetupAvailableJobs.Keys); - Assert.That(missingSpawnPoints, Has.Count.EqualTo(0), - $"There is no spawnpoint for {string.Join(", ", missingSpawnPoints)} on {mapProto}."); + var spawnPoints = entManager.EntityQuery() + .Where(x => x.SpawnType == SpawnPointType.Job) + .Select(x => x.Job!.Value); + + jobs.ExceptWith(spawnPoints); + Assert.That(jobs, Is.Empty,$"There is no spawnpoints for {string.Join(", ", jobs)} on {mapProto}."); } try diff --git a/Content.IntegrationTests/Tests/Round/JobTest.cs b/Content.IntegrationTests/Tests/Round/JobTest.cs new file mode 100644 index 0000000000..716e3cf4c2 --- /dev/null +++ b/Content.IntegrationTests/Tests/Round/JobTest.cs @@ -0,0 +1,222 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Content.IntegrationTests.Pair; +using Content.Server.GameTicking; +using Content.Server.Mind; +using Content.Server.Roles; +using Content.Shared.CCVar; +using Content.Shared.GameTicking; +using Content.Shared.Preferences; +using Content.Shared.Roles; +using Content.Shared.Roles.Jobs; +using Robust.Shared.Network; +using Robust.Shared.Prototypes; + +namespace Content.IntegrationTests.Tests.Round; + +[TestFixture] +public sealed class JobTest +{ + private static ProtoId _passenger = "Passenger"; + private static ProtoId _engineer = "StationEngineer"; + private static ProtoId _captain = "Captain"; + + private static string _map = "JobTestMap"; + + [TestPrototypes] + public static string JobTestMap = @$" +- type: gameMap + id: {_map} + mapName: {_map} + mapPath: /Maps/Test/empty.yml + minPlayers: 0 + stations: + Empty: + stationProto: StandardNanotrasenStation + components: + - type: StationNameSetup + mapNameTemplate: ""Empty"" + - type: StationJobs + availableJobs: + {_passenger}: [ -1, -1 ] + {_engineer}: [ -1, -1 ] + {_captain}: [ 1, 1 ] +"; + + public void AssertJob(TestPair pair, ProtoId job, NetUserId? user = null, bool isAntag = false) + { + var jobSys = pair.Server.System(); + var mindSys = pair.Server.System(); + var roleSys = pair.Server.System(); + var ticker = pair.Server.System(); + + user ??= pair.Client.User!.Value; + + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.InRound)); + Assert.That(ticker.PlayerGameStatuses[user.Value], Is.EqualTo(PlayerGameStatus.JoinedGame)); + + var uid = pair.Server.PlayerMan.SessionsDict.GetValueOrDefault(user.Value)?.AttachedEntity; + Assert.That(pair.Server.EntMan.EntityExists(uid)); + var mind = mindSys.GetMind(uid!.Value); + Assert.That(pair.Server.EntMan.EntityExists(mind)); + Assert.That(jobSys.MindTryGetJobId(mind, out var actualJob)); + Assert.That(actualJob, Is.EqualTo(job)); + Assert.That(roleSys.MindIsAntagonist(mind), Is.EqualTo(isAntag)); + } + + /// + /// Simple test that checks that starting the round spawns the player into the test map as a passenger. + /// + [Test] + public async Task StartRoundTest() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + DummyTicker = false, + Connected = true, + InLobby = true + }); + + pair.Server.CfgMan.SetCVar(CCVars.GameMap, _map); + var ticker = pair.Server.System(); + + // Initially in the lobby + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby)); + Assert.That(pair.Client.AttachedEntity, Is.Null); + Assert.That(ticker.PlayerGameStatuses[pair.Client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay)); + + // Ready up and start the round + ticker.ToggleReadyAll(true); + Assert.That(ticker.PlayerGameStatuses[pair.Client.User!.Value], Is.EqualTo(PlayerGameStatus.ReadyToPlay)); + await pair.Server.WaitPost(() => ticker.StartRound()); + await pair.RunTicksSync(10); + + AssertJob(pair, _passenger); + + await pair.Server.WaitPost(() => ticker.RestartRound()); + await pair.CleanReturnAsync(); + } + + /// + /// Check that job preferences are respected. + /// + [Test] + public async Task JobPreferenceTest() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + DummyTicker = false, + Connected = true, + InLobby = true + }); + + pair.Server.CfgMan.SetCVar(CCVars.GameMap, _map); + var ticker = pair.Server.System(); + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby)); + Assert.That(pair.Client.AttachedEntity, Is.Null); + + await pair.SetJobPriorities((_passenger, JobPriority.Medium), (_engineer, JobPriority.High)); + ticker.ToggleReadyAll(true); + await pair.Server.WaitPost(() => ticker.StartRound()); + await pair.RunTicksSync(10); + + AssertJob(pair, _engineer); + + await pair.Server.WaitPost(() => ticker.RestartRound()); + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby)); + await pair.SetJobPriorities((_passenger, JobPriority.High), (_engineer, JobPriority.Medium)); + ticker.ToggleReadyAll(true); + await pair.Server.WaitPost(() => ticker.StartRound()); + await pair.RunTicksSync(10); + + AssertJob(pair, _passenger); + + await pair.Server.WaitPost(() => ticker.RestartRound()); + await pair.CleanReturnAsync(); + } + + /// + /// Check high priority jobs (e.g., captain) are selected before other roles, even if it means a player does not + /// get their preferred job. + /// + [Test] + public async Task JobWeightTest() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + DummyTicker = false, + Connected = true, + InLobby = true + }); + + pair.Server.CfgMan.SetCVar(CCVars.GameMap, _map); + var ticker = pair.Server.System(); + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby)); + Assert.That(pair.Client.AttachedEntity, Is.Null); + + var captain = pair.Server.ProtoMan.Index(_captain); + var engineer = pair.Server.ProtoMan.Index(_engineer); + var passenger = pair.Server.ProtoMan.Index(_passenger); + Assert.That(captain.Weight, Is.GreaterThan(engineer.Weight)); + Assert.That(engineer.Weight, Is.EqualTo(passenger.Weight)); + + await pair.SetJobPriorities((_passenger, JobPriority.Medium), (_engineer, JobPriority.High), (_captain, JobPriority.Low)); + ticker.ToggleReadyAll(true); + await pair.Server.WaitPost(() => ticker.StartRound()); + await pair.RunTicksSync(10); + + AssertJob(pair, _captain); + + await pair.Server.WaitPost(() => ticker.RestartRound()); + await pair.CleanReturnAsync(); + } + + /// + /// Check that jobs are preferentially given to players that have marked those jobs as higher priority. + /// + [Test] + public async Task JobPriorityTest() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + DummyTicker = false, + Connected = true, + InLobby = true + }); + + pair.Server.CfgMan.SetCVar(CCVars.GameMap, _map); + var ticker = pair.Server.System(); + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby)); + Assert.That(pair.Client.AttachedEntity, Is.Null); + + await pair.Server.AddDummySessions(5); + await pair.RunTicksSync(5); + + var engineers = pair.Server.PlayerMan.Sessions.Select(x => x.UserId).ToList(); + var captain = engineers[3]; + engineers.RemoveAt(3); + + await pair.SetJobPriorities(captain, (_captain, JobPriority.High), (_engineer, JobPriority.Medium)); + foreach (var engi in engineers) + { + await pair.SetJobPriorities(engi, (_captain, JobPriority.Medium), (_engineer, JobPriority.High)); + } + + ticker.ToggleReadyAll(true); + await pair.Server.WaitPost(() => ticker.StartRound()); + await pair.RunTicksSync(10); + + AssertJob(pair, _captain, captain); + Assert.Multiple(() => + { + foreach (var engi in engineers) + { + AssertJob(pair, _engineer, engi); + } + }); + + await pair.Server.WaitPost(() => ticker.RestartRound()); + await pair.CleanReturnAsync(); + } +} diff --git a/Content.IntegrationTests/Tests/Station/StationJobsTest.cs b/Content.IntegrationTests/Tests/Station/StationJobsTest.cs index 0085472c33..d68fdafb76 100644 --- a/Content.IntegrationTests/Tests/Station/StationJobsTest.cs +++ b/Content.IntegrationTests/Tests/Station/StationJobsTest.cs @@ -7,7 +7,6 @@ using Content.Shared.Preferences; using Content.Shared.Roles; using Robust.Shared.GameObjects; using Robust.Shared.Log; -using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Prototypes; using Robust.Shared.Timing; @@ -46,8 +45,6 @@ public sealed class StationJobsTest stationProto: StandardNanotrasenStation components: - type: StationJobs - overflowJobs: - - Passenger availableJobs: TMime: [0, -1] TAssistant: [-1, -1] @@ -164,7 +161,6 @@ public sealed class StationJobsTest var server = pair.Server; var prototypeManager = server.ResolveDependency(); - var mapManager = server.ResolveDependency(); var fooStationProto = prototypeManager.Index("FooStation"); var entSysMan = server.ResolveDependency().EntitySysManager; var stationJobs = entSysMan.GetEntitySystem(); @@ -215,6 +211,8 @@ public sealed class StationJobsTest var server = pair.Server; var prototypeManager = server.ResolveDependency(); + var compFact = server.ResolveDependency(); + var name = compFact.GetComponentName(); await server.WaitAssertion(() => { @@ -233,11 +231,14 @@ public sealed class StationJobsTest { foreach (var (stationId, station) in gameMap.Stations) { - if (!station.StationComponentOverrides.TryGetComponent("StationJobs", out var comp)) + if (!station.StationComponentOverrides.TryGetComponent(name, out var comp)) continue; - foreach (var (job, _) in ((StationJobsComponent) comp).SetupAvailableJobs) + foreach (var (job, array) in ((StationJobsComponent) comp).SetupAvailableJobs) { + Assert.That(array.Length, Is.EqualTo(2)); + Assert.That(array[0] is -1 or >= 0); + Assert.That(array[1] is -1 or >= 0); Assert.That(invalidJobs, Does.Not.Contain(job), $"Station {stationId} contains job prototype {job} which cannot be present roundstart."); } } diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs index be6c7196d5..cd03af7087 100644 --- a/Content.Server/Database/ServerDbBase.cs +++ b/Content.Server/Database/ServerDbBase.cs @@ -15,6 +15,7 @@ using Content.Shared.Humanoid.Markings; using Content.Shared.Preferences; using Content.Shared.Preferences.Loadouts; using Content.Shared.Roles; +using Content.Shared.Traits; using Microsoft.EntityFrameworkCore; using Robust.Shared.Enums; using Robust.Shared.Network; @@ -183,9 +184,9 @@ namespace Content.Server.Database private static HumanoidCharacterProfile ConvertProfiles(Profile profile) { - var jobs = profile.Jobs.ToDictionary(j => j.JobName, j => (JobPriority) j.Priority); - var antags = profile.Antags.Select(a => a.AntagName); - var traits = profile.Traits.Select(t => t.TraitName); + var jobs = profile.Jobs.ToDictionary(j => new ProtoId(j.JobName), j => (JobPriority) j.Priority); + var antags = profile.Antags.Select(a => new ProtoId(a.AntagName)); + var traits = profile.Traits.Select(t => new ProtoId(t.TraitName)); var sex = Sex.Male; if (Enum.TryParse(profile.Sex, true, out var sexVal)) diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 6eb42b65c0..98fcc64410 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -239,7 +239,7 @@ namespace Content.Server.GameTicking HumanoidCharacterProfile profile; if (_prefsManager.TryGetCachedPreferences(userId, out var preferences)) { - profile = (HumanoidCharacterProfile) preferences.GetProfile(preferences.SelectedCharacterIndex); + profile = (HumanoidCharacterProfile) preferences.SelectedCharacter; } else { diff --git a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs index f7c15a2340..8de458b6ee 100644 --- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs +++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs @@ -305,11 +305,7 @@ namespace Content.Server.Preferences.Managers return usernames .Select(p => (_cachedPlayerPrefs[p].Prefs, p)) .Where(p => p.Prefs != null) - .Select(p => - { - var idx = p.Prefs!.SelectedCharacterIndex; - return new KeyValuePair(p.p, p.Prefs!.GetProfile(idx)); - }); + .Select(p => new KeyValuePair(p.p, p.Prefs!.SelectedCharacter)); } internal static bool ShouldStorePrefs(LoginType loginType) diff --git a/Content.Server/Spawners/Components/SpawnPointComponent.cs b/Content.Server/Spawners/Components/SpawnPointComponent.cs index 5cf231f224..c6d14dfeb3 100644 --- a/Content.Server/Spawners/Components/SpawnPointComponent.cs +++ b/Content.Server/Spawners/Components/SpawnPointComponent.cs @@ -6,11 +6,8 @@ namespace Content.Server.Spawners.Components; [RegisterComponent] public sealed partial class SpawnPointComponent : Component, ISpawnPoint { - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - - [ViewVariables(VVAccess.ReadWrite)] [DataField("job_id")] - private string? _jobId; + public ProtoId? Job; /// /// The type of spawn point @@ -18,11 +15,9 @@ public sealed partial class SpawnPointComponent : Component, ISpawnPoint [DataField("spawn_type"), ViewVariables(VVAccess.ReadWrite)] public SpawnPointType SpawnType { get; set; } = SpawnPointType.Unset; - public JobPrototype? Job => string.IsNullOrEmpty(_jobId) ? null : _prototypeManager.Index(_jobId); - public override string ToString() { - return $"{_jobId} {SpawnType}"; + return $"{Job} {SpawnType}"; } } diff --git a/Content.Server/Spawners/EntitySystems/SpawnPointSystem.cs b/Content.Server/Spawners/EntitySystems/SpawnPointSystem.cs index 5be4ff10f4..be555dd54a 100644 --- a/Content.Server/Spawners/EntitySystems/SpawnPointSystem.cs +++ b/Content.Server/Spawners/EntitySystems/SpawnPointSystem.cs @@ -39,7 +39,7 @@ public sealed class SpawnPointSystem : EntitySystem if (_gameTicker.RunLevel != GameRunLevel.InRound && spawnPoint.SpawnType == SpawnPointType.Job && - (args.Job == null || spawnPoint.Job?.ID == args.Job.Prototype)) + (args.Job == null || spawnPoint.Job == args.Job.Prototype)) { possiblePositions.Add(xform.Coordinates); } diff --git a/Content.Server/Station/Components/StationJobsComponent.cs b/Content.Server/Station/Components/StationJobsComponent.cs index 74399bf412..3681ec9674 100644 --- a/Content.Server/Station/Components/StationJobsComponent.cs +++ b/Content.Server/Station/Components/StationJobsComponent.cs @@ -1,4 +1,5 @@ -using Content.Server.Station.Systems; +using System.Linq; +using Content.Server.Station.Systems; using Content.Shared.Roles; using JetBrains.Annotations; using Robust.Shared.Network; @@ -14,25 +15,21 @@ namespace Content.Server.Station.Components; [RegisterComponent, Access(typeof(StationJobsSystem)), PublicAPI] public sealed partial class StationJobsComponent : Component { - /// - /// Total *round-start* jobs at station start. - /// - [DataField("roundStartTotalJobs")] public int RoundStartTotalJobs; - /// /// Total *mid-round* jobs at station start. + /// This is inferred automatically from . /// - [DataField("midRoundTotalJobs")] public int MidRoundTotalJobs; + [ViewVariables] public int MidRoundTotalJobs; /// /// Current total jobs. /// - [DataField("totalJobs")] public int TotalJobs; + [DataField] public int TotalJobs; /// /// Station is running on extended access. /// - [DataField("extendedAccess")] public bool ExtendedAccess; + [DataField] public bool ExtendedAccess; /// /// If there are less than or equal this amount of players in the game at round start, @@ -41,7 +38,7 @@ public sealed partial class StationJobsComponent : Component /// /// Set to -1 to disable extended access. /// - [DataField("extendedAccessThreshold")] + [DataField] public int ExtendedAccessThreshold { get; set; } = 15; /// @@ -54,28 +51,20 @@ public sealed partial class StationJobsComponent : Component public float? PercentJobsRemaining => MidRoundTotalJobs > 0 ? TotalJobs / (float) MidRoundTotalJobs : null; /// - /// The current list of jobs. + /// The current list of jobs of available jobs. Null implies that is no limit. /// /// /// This should not be mutated or used directly unless you really know what you're doing, go through StationJobsSystem. /// - [DataField("jobList", customTypeSerializer: typeof(PrototypeIdDictionarySerializer))] - public Dictionary JobList = new(); - - /// - /// The round-start list of jobs. - /// - /// - /// This should not be mutated, ever. - /// - [DataField("roundStartJobList", customTypeSerializer: typeof(PrototypeIdDictionarySerializer))] - public Dictionary RoundStartJobList = new(); + [DataField] + public Dictionary, int?> JobList = new(); /// /// Overflow jobs that round-start can spawn infinitely many of. + /// This is inferred automatically from . /// - [DataField("overflowJobs", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))] - public HashSet OverflowJobs = new(); + [ViewVariables] + public IReadOnlySet> OverflowJobs = default!; /// /// A dictionary relating a NetUserId to the jobs they have on station. @@ -84,7 +73,10 @@ public sealed partial class StationJobsComponent : Component [DataField] public Dictionary>> PlayerJobs = new(); - [DataField("availableJobs", required: true, - customTypeSerializer: typeof(PrototypeIdDictionarySerializer, JobPrototype>))] - public Dictionary> SetupAvailableJobs = default!; + /// + /// Mapping of jobs to an int[2] array that specifies jobs available at round start, and midround. + /// Negative values implies that there is no limit. + /// + [DataField("availableJobs", required: true)] + public Dictionary, int[]> SetupAvailableJobs = default!; } diff --git a/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs b/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs index c3c3865c7b..e145e233e9 100644 --- a/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs +++ b/Content.Server/Station/Systems/StationJobsSystem.Roundstart.cs @@ -52,23 +52,23 @@ public sealed partial class StationJobsSystem /// 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. /// - public Dictionary AssignJobs(Dictionary profiles, IReadOnlyList stations, bool useRoundStartJobs = true) + public Dictionary?, EntityUid)> AssignJobs(Dictionary profiles, IReadOnlyList stations, bool useRoundStartJobs = true) { DebugTools.Assert(stations.Count > 0); InitializeRoundStart(); if (profiles.Count == 0) - return new Dictionary(); + return new(); // We need to modify this collection later, so make a copy of it. profiles = profiles.ShallowClone(); // Player <-> (job, station) - var assigned = new Dictionary(profiles.Count); + var assigned = new Dictionary?, 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>(); + var stationJobs = new Dictionary, int?>>(); foreach (var station in stations) { if (useRoundStartJobs) @@ -83,15 +83,15 @@ public sealed partial class StationJobsSystem // We reuse this collection. It tracks what jobs we're currently trying to select players for. - var currentlySelectingJobs = new Dictionary>(stations.Count); + var currentlySelectingJobs = new Dictionary, int?>>(stations.Count); foreach (var station in stations) { - currentlySelectingJobs.Add(station, new Dictionary()); + currentlySelectingJobs.Add(station, new Dictionary, int?>()); } // And these. // Tracks what players are available for a given job in the current iteration of selection. - var jobPlayerOptions = new Dictionary>(); + var jobPlayerOptions = new Dictionary, HashSet>(); // Tracks the total number of slots for the given stations in the current iteration of selection. var stationTotalSlots = new Dictionary(stations.Count); // The share of the players each station gets in the current iteration of job selection. @@ -112,7 +112,7 @@ public sealed partial class StationJobsSystem 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) + void AssignPlayer(NetUserId player, ProtoId job, EntityUid station) { // Remove the player from all possible jobs as that's faster than actually checking what they have selected. foreach (var (k, players) in jobPlayerOptions) @@ -273,8 +273,11 @@ public sealed partial class StationJobsSystem /// All players that might need an overflow assigned. /// Player character profiles. /// The stations to consider for spawn location. - public void AssignOverflowJobs(ref Dictionary assignedJobs, - IEnumerable allPlayersToAssign, IReadOnlyDictionary profiles, IReadOnlyList stations) + public void AssignOverflowJobs( + ref Dictionary?, EntityUid)> assignedJobs, + IEnumerable allPlayersToAssign, + IReadOnlyDictionary profiles, + IReadOnlyList stations) { var givenStations = stations.ToList(); if (givenStations.Count == 0) diff --git a/Content.Server/Station/Systems/StationJobsSystem.cs b/Content.Server/Station/Systems/StationJobsSystem.cs index 3bfa815af1..307610d136 100644 --- a/Content.Server/Station/Systems/StationJobsSystem.cs +++ b/Content.Server/Station/Systems/StationJobsSystem.cs @@ -3,6 +3,7 @@ using System.Linq; using Content.Server.GameTicking; using Content.Server.Station.Components; using Content.Shared.CCVar; +using Content.Shared.FixedPoint; using Content.Shared.GameTicking; using Content.Shared.Preferences; using Content.Shared.Roles; @@ -31,12 +32,25 @@ public sealed partial class StationJobsSystem : EntitySystem public override void Initialize() { SubscribeLocalEvent(OnStationInitialized); + SubscribeLocalEvent(OnInit); SubscribeLocalEvent(OnStationRenamed); SubscribeLocalEvent(OnStationDeletion); SubscribeLocalEvent(OnPlayerJoinedLobby); Subs.CVar(_configurationManager, CCVars.GameDisallowLateJoins, _ => UpdateJobsAvailable(), true); } + private void OnInit(Entity ent, ref ComponentInit args) + { + ent.Comp.MidRoundTotalJobs = ent.Comp.SetupAvailableJobs.Values + .Select(x => Math.Max(x[1], 0)) + .Sum(); + + ent.Comp.OverflowJobs = ent.Comp.SetupAvailableJobs + .Where(x => x.Value[0] < 0) + .Select(x => x.Key) + .ToHashSet(); + } + public override void Update(float _) { if (_availableJobsDirty) @@ -57,28 +71,11 @@ public sealed partial class StationJobsSystem : EntitySystem if (!TryComp(msg.Station, out var stationJobs)) return; - var mapJobList = stationJobs.SetupAvailableJobs; + stationJobs.JobList = stationJobs.SetupAvailableJobs.ToDictionary( + x => x.Key, + x=> (int?)(x.Value[1] < 0 ? null : x.Value[1])); - 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 = stationJobs.OverflowJobs.ToHashSet(); + stationJobs.TotalJobs = stationJobs.JobList.Values.Select(x => x ?? 0).Sum(); UpdateJobsAvailable(); } @@ -141,7 +138,11 @@ public sealed partial class StationJobsSystem : EntitySystem /// Resolve pattern, station jobs component of the station. /// Whether or not slot adjustment was a success. /// Thrown when the given station is not a station. - public bool TryAdjustJobSlot(EntityUid station, string jobPrototypeId, int amount, bool createSlot = false, bool clamp = false, + public bool TryAdjustJobSlot(EntityUid station, + string jobPrototypeId, + int amount, + bool createSlot = false, + bool clamp = false, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) @@ -156,7 +157,11 @@ public sealed partial class StationJobsSystem : EntitySystem // - 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)) + + if (amount == 0) + return true; + + switch (jobList.TryGetValue(jobPrototypeId, out var available)) { case false when amount < 0: return false; @@ -164,31 +169,20 @@ public sealed partial class StationJobsSystem : EntitySystem if (!createSlot) return false; stationJobs.TotalJobs += amount; - jobList[jobPrototypeId] = (uint?)amount; + jobList[jobPrototypeId] = amount; UpdateJobsAvailable(); return true; case true: // Job is unlimited so just say we adjusted it and do nothing. - if (jobList[jobPrototypeId] == null) + if (available is not {} avail) return true; // Would remove more jobs than we have available. - if (amount < 0 && (jobList[jobPrototypeId] + amount < 0 && !clamp)) + if (available + 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); - } - + jobList[jobPrototypeId] = Math.Max(avail + amount, 0); + stationJobs.TotalJobs = jobList.Values.Select(x => x ?? 0).Sum(); UpdateJobsAvailable(); return true; } @@ -239,7 +233,10 @@ public sealed partial class StationJobsSystem : EntitySystem /// Resolve pattern, station jobs component of the station. /// Whether or not setting the value succeeded. /// Thrown when the given station is not a station. - public bool TrySetJobSlot(EntityUid station, string jobPrototypeId, int amount, bool createSlot = false, + public bool TrySetJobSlot(EntityUid station, + string jobPrototypeId, + int amount, + bool createSlot = false, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) @@ -255,13 +252,13 @@ public sealed partial class StationJobsSystem : EntitySystem if (!createSlot) return false; stationJobs.TotalJobs += amount; - jobList[jobPrototypeId] = (uint?)amount; + jobList[jobPrototypeId] = amount; UpdateJobsAvailable(); return true; case true: - stationJobs.TotalJobs += amount - (int) (jobList[jobPrototypeId] ?? 0); + stationJobs.TotalJobs += amount - (jobList[jobPrototypeId] ?? 0); - jobList[jobPrototypeId] = (uint)amount; + jobList[jobPrototypeId] = amount; UpdateJobsAvailable(); return true; } @@ -289,8 +286,8 @@ public sealed partial class StationJobsSystem : EntitySystem 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; + if (stationJobs.JobList.TryGetValue(jobPrototypeId, out var existing)) + stationJobs.TotalJobs -= existing ?? 0; stationJobs.JobList[jobPrototypeId] = null; @@ -319,8 +316,7 @@ public sealed partial class StationJobsSystem : EntitySystem 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; + return stationJobs.JobList.TryGetValue(jobPrototypeId, out var job) && job == null; } /// @@ -328,7 +324,7 @@ public sealed partial class StationJobsSystem : EntitySystem /// Job to get slot info for. /// The number of slots remaining. Null if infinite. /// Resolve pattern, station jobs component of the station. - public bool TryGetJobSlot(EntityUid station, JobPrototype job, out uint? slots, StationJobsComponent? stationJobs = null) + public bool TryGetJobSlot(EntityUid station, JobPrototype job, out int? slots, StationJobsComponent? stationJobs = null) { return TryGetJobSlot(station, job.ID, out slots, stationJobs); } @@ -343,21 +339,12 @@ public sealed partial class StationJobsSystem : EntitySystem /// Whether or not the slot exists. /// Thrown when the given station is not a station. /// slots will be null if the slot doesn't exist, as well, so make sure to check the return value. - public bool TryGetJobSlot(EntityUid station, string jobPrototypeId, out uint? slots, StationJobsComponent? stationJobs = null) + public bool TryGetJobSlot(EntityUid station, string jobPrototypeId, out int? slots, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); - 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; - } + return stationJobs.JobList.TryGetValue(jobPrototypeId, out slots); } /// @@ -367,12 +354,14 @@ public sealed partial class StationJobsSystem : EntitySystem /// Resolve pattern, station jobs component of the station. /// Set containing all jobs available. /// Thrown when the given station is not a station. - public IReadOnlySet GetAvailableJobs(EntityUid station, StationJobsComponent? stationJobs = null) + public IEnumerable> GetAvailableJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); - return stationJobs.JobList.Where(x => x.Value != 0).Select(x => x.Key).ToHashSet(); + return stationJobs.JobList + .Where(x => x.Value != 0) + .Select(x => x.Key); } /// @@ -382,12 +371,12 @@ public sealed partial class StationJobsSystem : EntitySystem /// Resolve pattern, station jobs component of the station. /// Set containing all overflow jobs available. /// Thrown when the given station is not a station. - public IReadOnlySet GetOverflowJobs(EntityUid station, StationJobsComponent? stationJobs = null) + public IReadOnlySet> GetOverflowJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); - return stationJobs.OverflowJobs.ToHashSet(); + return stationJobs.OverflowJobs; } /// @@ -397,7 +386,7 @@ public sealed partial class StationJobsSystem : EntitySystem /// Resolve pattern, station jobs component of the station. /// List of all jobs on the station. /// Thrown when the given station is not a station. - public IReadOnlyDictionary GetJobs(EntityUid station, StationJobsComponent? stationJobs = null) + public IReadOnlyDictionary, int?> GetJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); @@ -412,12 +401,14 @@ public sealed partial class StationJobsSystem : EntitySystem /// Resolve pattern, station jobs component of the station. /// List of all round-start jobs. /// Thrown when the given station is not a station. - public IReadOnlyDictionary GetRoundStartJobs(EntityUid station, StationJobsComponent? stationJobs = null) + public Dictionary, int?> GetRoundStartJobs(EntityUid station, StationJobsComponent? stationJobs = null) { if (!Resolve(station, ref stationJobs)) throw new ArgumentException("Tried to use a non-station entity as a station!", nameof(station)); - return stationJobs.RoundStartJobList; + return stationJobs.SetupAvailableJobs.ToDictionary( + x => x.Key, + x=> (int?)(x.Value[0] < 0 ? null : x.Value[0])); } /// @@ -428,13 +419,13 @@ public sealed partial class StationJobsSystem : EntitySystem /// Whether or not to pick from the overflow list. /// A set of disallowed jobs, if any. /// The selected job, if any. - public string? PickBestAvailableJobWithPriority(EntityUid station, IReadOnlyDictionary jobPriorities, bool pickOverflows, IReadOnlySet>? disallowedJobs = null) + public ProtoId? PickBestAvailableJobWithPriority(EntityUid station, IReadOnlyDictionary, JobPriority> jobPriorities, bool pickOverflows, IReadOnlySet>? disallowedJobs = null) { if (station == EntityUid.Invalid) return null; var available = GetAvailableJobs(station); - bool TryPick(JobPriority priority, [NotNullWhen(true)] out string? jobId) + bool TryPick(JobPriority priority, [NotNullWhen(true)] out ProtoId? jobId) { var filtered = jobPriorities .Where(p => @@ -474,7 +465,10 @@ public sealed partial class StationJobsSystem : EntitySystem return null; var overflows = GetOverflowJobs(station); - return overflows.Count != 0 ? _random.Pick(overflows) : null; + if (overflows.Count == 0) + return null; + + return _random.Pick(overflows); } #endregion Public API @@ -483,7 +477,7 @@ public sealed partial class StationJobsSystem : EntitySystem private bool _availableJobsDirty; - private TickerJobsAvailableEvent _cachedAvailableJobs = new (new Dictionary(), new Dictionary>()); + private TickerJobsAvailableEvent _cachedAvailableJobs = new(new(), new()); /// /// Assembles an event from the current available-to-play jobs. @@ -494,9 +488,9 @@ public sealed partial class StationJobsSystem : EntitySystem { // If late join is disallowed, return no available jobs. if (_gameTicker.DisallowLateJoin) - return new TickerJobsAvailableEvent(new Dictionary(), new Dictionary>()); + return new TickerJobsAvailableEvent(new(), new()); - var jobs = new Dictionary>(); + var jobs = new Dictionary, int?>>(); var stationNames = new Dictionary(); var query = EntityQueryEnumerator(); diff --git a/Content.Shared/GameTicking/SharedGameTicker.cs b/Content.Shared/GameTicking/SharedGameTicker.cs index 95da4f4c38..308476baa8 100644 --- a/Content.Shared/GameTicking/SharedGameTicker.cs +++ b/Content.Shared/GameTicking/SharedGameTicker.cs @@ -1,5 +1,6 @@ using Content.Shared.Roles; using Robust.Shared.Network; +using Robust.Shared.Prototypes; using Robust.Shared.Replays; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Markdown.Mapping; @@ -128,19 +129,17 @@ namespace Content.Shared.GameTicking } [Serializable, NetSerializable] - public sealed class TickerJobsAvailableEvent : EntityEventArgs + public sealed class TickerJobsAvailableEvent( + Dictionary stationNames, + Dictionary, int?>> jobsAvailableByStation) + : EntityEventArgs { /// /// The Status of the Player in the lobby (ready, observer, ...) /// - public Dictionary> JobsAvailableByStation { get; } - public Dictionary StationNames { get; } + public Dictionary, int?>> JobsAvailableByStation { get; } = jobsAvailableByStation; - public TickerJobsAvailableEvent(Dictionary stationNames, Dictionary> jobsAvailableByStation) - { - StationNames = stationNames; - JobsAvailableByStation = jobsAvailableByStation; - } + public Dictionary StationNames { get; } = stationNames; } [Serializable, NetSerializable, DataDefinition] diff --git a/Content.Shared/Preferences/HumanoidCharacterProfile.cs b/Content.Shared/Preferences/HumanoidCharacterProfile.cs index bd55bbb40a..20c54cd268 100644 --- a/Content.Shared/Preferences/HumanoidCharacterProfile.cs +++ b/Content.Shared/Preferences/HumanoidCharacterProfile.cs @@ -35,7 +35,7 @@ namespace Content.Shared.Preferences /// Job preferences for initial spawn. /// [DataField] - private Dictionary _jobPriorities = new() + private Dictionary, JobPriority> _jobPriorities = new() { { SharedGameTicker.FallbackOverflowJob, JobPriority.High @@ -46,13 +46,13 @@ namespace Content.Shared.Preferences /// Antags we have opted in to. /// [DataField] - private HashSet _antagPreferences = new(); + private HashSet> _antagPreferences = new(); /// /// Enabled traits. /// [DataField] - private HashSet _traitPreferences = new(); + private HashSet> _traitPreferences = new(); /// /// @@ -75,7 +75,7 @@ namespace Content.Shared.Preferences /// Associated for this profile. /// [DataField] - public string Species { get; set; } = SharedHumanoidAppearanceSystem.DefaultSpecies; + public ProtoId Species { get; set; } = SharedHumanoidAppearanceSystem.DefaultSpecies; [DataField] public int Age { get; set; } = 18; @@ -106,17 +106,17 @@ namespace Content.Shared.Preferences /// /// /// - public IReadOnlyDictionary JobPriorities => _jobPriorities; + public IReadOnlyDictionary, JobPriority> JobPriorities => _jobPriorities; /// /// /// - public IReadOnlySet AntagPreferences => _antagPreferences; + public IReadOnlySet> AntagPreferences => _antagPreferences; /// /// /// - public IReadOnlySet TraitPreferences => _traitPreferences; + public IReadOnlySet> TraitPreferences => _traitPreferences; /// /// If we're unable to get one of our preferred jobs do we spawn as a fallback job or do we stay in lobby. @@ -134,10 +134,10 @@ namespace Content.Shared.Preferences Gender gender, HumanoidCharacterAppearance appearance, SpawnPriorityPreference spawnPriority, - Dictionary jobPriorities, + Dictionary, JobPriority> jobPriorities, PreferenceUnavailableMode preferenceUnavailable, - HashSet antagPreferences, - HashSet traitPreferences, + HashSet> antagPreferences, + HashSet> traitPreferences, Dictionary loadouts) { Name = name; @@ -153,6 +153,20 @@ namespace Content.Shared.Preferences _antagPreferences = antagPreferences; _traitPreferences = traitPreferences; _loadouts = loadouts; + + var hasHighPrority = false; + foreach (var (key, value) in _jobPriorities) + { + if (value == JobPriority.Never) + _jobPriorities.Remove(key); + else if (value != JobPriority.High) + continue; + + if (hasHighPrority) + _jobPriorities[key] = JobPriority.Medium; + + hasHighPrority = true; + } } /// Copy constructor @@ -165,10 +179,10 @@ namespace Content.Shared.Preferences other.Gender, other.Appearance.Clone(), other.SpawnPriority, - new Dictionary(other.JobPriorities), + new Dictionary, JobPriority>(other.JobPriorities), other.PreferenceUnavailable, - new HashSet(other.AntagPreferences), - new HashSet(other.TraitPreferences), + new HashSet>(other.AntagPreferences), + new HashSet>(other.TraitPreferences), new Dictionary(other.Loadouts)) { } @@ -289,21 +303,48 @@ namespace Content.Shared.Preferences return new(this) { SpawnPriority = spawnPriority }; } - public HumanoidCharacterProfile WithJobPriorities(IEnumerable> jobPriorities) + public HumanoidCharacterProfile WithJobPriorities(IEnumerable, JobPriority>> jobPriorities) { + var dictionary = new Dictionary, JobPriority>(jobPriorities); + var hasHighPrority = false; + + foreach (var (key, value) in dictionary) + { + if (value == JobPriority.Never) + dictionary.Remove(key); + else if (value != JobPriority.High) + continue; + + if (hasHighPrority) + dictionary[key] = JobPriority.Medium; + + hasHighPrority = true; + } + return new(this) { - _jobPriorities = new Dictionary(jobPriorities), + _jobPriorities = dictionary }; } - public HumanoidCharacterProfile WithJobPriority(string jobId, JobPriority priority) + public HumanoidCharacterProfile WithJobPriority(ProtoId jobId, JobPriority priority) { - var dictionary = new Dictionary(_jobPriorities); + var dictionary = new Dictionary, JobPriority>(_jobPriorities); if (priority == JobPriority.Never) { dictionary.Remove(jobId); } + else if (priority == JobPriority.High) + { + // There can only ever be one high priority job. + foreach (var (job, value) in dictionary) + { + if (value == JobPriority.High) + dictionary[job] = JobPriority.Medium; + } + + dictionary[jobId] = priority; + } else { dictionary[jobId] = priority; @@ -320,17 +361,17 @@ namespace Content.Shared.Preferences return new(this) { PreferenceUnavailable = mode }; } - public HumanoidCharacterProfile WithAntagPreferences(IEnumerable antagPreferences) + public HumanoidCharacterProfile WithAntagPreferences(IEnumerable> antagPreferences) { return new(this) { - _antagPreferences = new HashSet(antagPreferences), + _antagPreferences = new (antagPreferences), }; } - public HumanoidCharacterProfile WithAntagPreference(string antagId, bool pref) + public HumanoidCharacterProfile WithAntagPreference(ProtoId antagId, bool pref) { - var list = new HashSet(_antagPreferences); + var list = new HashSet>(_antagPreferences); if (pref) { list.Add(antagId); @@ -346,16 +387,16 @@ namespace Content.Shared.Preferences }; } - public HumanoidCharacterProfile WithTraitPreference(string traitId, string? categoryId, bool pref) + public HumanoidCharacterProfile WithTraitPreference(ProtoId traitId, string? categoryId, bool pref) { var prototypeManager = IoCManager.Resolve(); - var traitProto = prototypeManager.Index(traitId); + var traitProto = prototypeManager.Index(traitId); TraitCategoryPrototype? categoryProto = null; if (categoryId != null && categoryId != "default") categoryProto = prototypeManager.Index(categoryId); - var list = new HashSet(_traitPreferences); + var list = new HashSet>(_traitPreferences); if (pref) { @@ -372,7 +413,7 @@ namespace Content.Shared.Preferences var count = 0; foreach (var trait in list) { - var traitProtoTemp = prototypeManager.Index(trait); + var traitProtoTemp = prototypeManager.Index(trait); count += traitProtoTemp.Cost; } @@ -514,7 +555,7 @@ namespace Content.Shared.Preferences _ => SpawnPriorityPreference.None // Invalid enum values. }; - var priorities = new Dictionary(JobPriorities + var priorities = new Dictionary, JobPriority>(JobPriorities .Where(p => prototypeManager.TryIndex(p.Key, out var job) && job.SetPreference && p.Value switch { JobPriority.Never => false, // Drop never since that's assumed default. @@ -524,6 +565,17 @@ namespace Content.Shared.Preferences _ => false })); + var hasHighPrio = false; + foreach (var (key, value) in priorities) + { + if (value != JobPriority.High) + continue; + + if (hasHighPrio) + priorities[key] = JobPriority.Medium; + hasHighPrio = true; + } + var antags = AntagPreferences .Where(id => prototypeManager.TryIndex(id, out var antag) && antag.SetPreference) .ToList(); diff --git a/Content.Shared/Roles/JobPrototype.cs b/Content.Shared/Roles/JobPrototype.cs index 0b3bfb4438..dd67e7b104 100644 --- a/Content.Shared/Roles/JobPrototype.cs +++ b/Content.Shared/Roles/JobPrototype.cs @@ -63,8 +63,8 @@ namespace Content.Shared.Roles public bool CanBeAntag { get; private set; } = true; /// - /// Whether this job is a head. - /// The job system will try to pick heads before other jobs on the same priority level. + /// The "weight" or importance of this job. If this number is large, the job system will assign this job + /// before assigning other jobs. /// [DataField("weight")] public int Weight { get; private set; } diff --git a/Content.Shared/Roles/Jobs/SharedJobSystem.cs b/Content.Shared/Roles/Jobs/SharedJobSystem.cs index fcf7605278..ce4428d9fe 100644 --- a/Content.Shared/Roles/Jobs/SharedJobSystem.cs +++ b/Content.Shared/Roles/Jobs/SharedJobSystem.cs @@ -118,6 +118,18 @@ public abstract class SharedJobSystem : EntitySystem _prototypes.TryIndex(comp.Prototype, out prototype); } + public bool MindTryGetJobId([NotNullWhen(true)] EntityUid? mindId, out ProtoId? job) + { + if (!TryComp(mindId, out JobComponent? comp)) + { + job = null; + return false; + } + + job = comp.Prototype; + return true; + } + /// /// Tries to get the job name for this mind. /// Returns unknown if not found. diff --git a/Resources/Prototypes/Maps/arenas.yml b/Resources/Prototypes/Maps/arenas.yml index 32f8543722..7ad7a16bc2 100644 --- a/Resources/Prototypes/Maps/arenas.yml +++ b/Resources/Prototypes/Maps/arenas.yml @@ -10,7 +10,5 @@ - type: StationNameSetup mapNameTemplate: "Meteor Arena" - type: StationJobs - overflowJobs: - - Passenger availableJobs: Passenger: [ -1, -1 ] diff --git a/Resources/Prototypes/Maps/atlas.yml b/Resources/Prototypes/Maps/atlas.yml index ef7523c727..6fe3eff030 100644 --- a/Resources/Prototypes/Maps/atlas.yml +++ b/Resources/Prototypes/Maps/atlas.yml @@ -14,8 +14,6 @@ !type:NanotrasenNameGenerator prefixCreator: 'R4' # R4407/Goon. GS isn't as cool sounding. - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/bagel.yml b/Resources/Prototypes/Maps/bagel.yml index 24ca17339f..ea06153d7e 100644 --- a/Resources/Prototypes/Maps/bagel.yml +++ b/Resources/Prototypes/Maps/bagel.yml @@ -16,8 +16,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_lox.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/box.yml b/Resources/Prototypes/Maps/box.yml index 50826fc5e0..89ba3779c6 100644 --- a/Resources/Prototypes/Maps/box.yml +++ b/Resources/Prototypes/Maps/box.yml @@ -15,8 +15,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_box.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/cluster.yml b/Resources/Prototypes/Maps/cluster.yml index fe445b0081..10a12c4f44 100644 --- a/Resources/Prototypes/Maps/cluster.yml +++ b/Resources/Prototypes/Maps/cluster.yml @@ -16,8 +16,6 @@ !type:NanotrasenNameGenerator prefixCreator: '14' - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/core.yml b/Resources/Prototypes/Maps/core.yml index 6b85aca51d..d7a15f2b1d 100644 --- a/Resources/Prototypes/Maps/core.yml +++ b/Resources/Prototypes/Maps/core.yml @@ -18,8 +18,6 @@ - type: StationCargoShuttle path: /Maps/Shuttles/cargo_core.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Bartender: [ 2, 2 ] diff --git a/Resources/Prototypes/Maps/debug.yml b/Resources/Prototypes/Maps/debug.yml index 2f475c1e57..8d4cc550a2 100644 --- a/Resources/Prototypes/Maps/debug.yml +++ b/Resources/Prototypes/Maps/debug.yml @@ -10,8 +10,6 @@ - type: StationNameSetup mapNameTemplate: "Empty" - type: StationJobs - overflowJobs: - - Passenger availableJobs: Passenger: [ -1, -1 ] @@ -27,8 +25,6 @@ - type: StationNameSetup mapNameTemplate: "Dev" - type: StationJobs - overflowJobs: - - Captain availableJobs: Captain: [ -1, -1 ] @@ -44,7 +40,5 @@ - type: StationNameSetup mapNameTemplate: "TEG" - type: StationJobs - overflowJobs: - - ChiefEngineer availableJobs: ChiefEngineer: [ -1, -1 ] diff --git a/Resources/Prototypes/Maps/europa.yml b/Resources/Prototypes/Maps/europa.yml index 0c9f1d975b..412e1b4656 100644 --- a/Resources/Prototypes/Maps/europa.yml +++ b/Resources/Prototypes/Maps/europa.yml @@ -21,8 +21,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_transit.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Bartender: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/fland.yml b/Resources/Prototypes/Maps/fland.yml index f0c35f99e5..f22c44cb63 100644 --- a/Resources/Prototypes/Maps/fland.yml +++ b/Resources/Prototypes/Maps/fland.yml @@ -17,8 +17,6 @@ - type: StationCargoShuttle path: /Maps/Shuttles/cargo_fland.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/marathon.yml b/Resources/Prototypes/Maps/marathon.yml index f82ee1d434..32ad8d576c 100644 --- a/Resources/Prototypes/Maps/marathon.yml +++ b/Resources/Prototypes/Maps/marathon.yml @@ -16,8 +16,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_rod.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/meta.yml b/Resources/Prototypes/Maps/meta.yml index 2bee606e95..ebd6954aa7 100644 --- a/Resources/Prototypes/Maps/meta.yml +++ b/Resources/Prototypes/Maps/meta.yml @@ -15,8 +15,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_meta.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/oasis.yml b/Resources/Prototypes/Maps/oasis.yml index b5e0f97190..a4cc6eb43d 100644 --- a/Resources/Prototypes/Maps/oasis.yml +++ b/Resources/Prototypes/Maps/oasis.yml @@ -15,8 +15,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_delta.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] @@ -62,4 +60,4 @@ Passenger: [ -1, -1 ] Clown: [ 1, 1 ] Mime: [ 1, 1 ] - Musician: [ 1, 1 ] \ No newline at end of file + Musician: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/omega.yml b/Resources/Prototypes/Maps/omega.yml index f90c5f5b65..b94fdbc05d 100644 --- a/Resources/Prototypes/Maps/omega.yml +++ b/Resources/Prototypes/Maps/omega.yml @@ -16,8 +16,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_omega.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/origin.yml b/Resources/Prototypes/Maps/origin.yml index 1281489891..24214b37a1 100644 --- a/Resources/Prototypes/Maps/origin.yml +++ b/Resources/Prototypes/Maps/origin.yml @@ -15,8 +15,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_courser.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/packed.yml b/Resources/Prototypes/Maps/packed.yml index 20d6c7a7bd..b844636bf8 100644 --- a/Resources/Prototypes/Maps/packed.yml +++ b/Resources/Prototypes/Maps/packed.yml @@ -14,8 +14,6 @@ !type:NanotrasenNameGenerator prefixCreator: 'VG' - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/reach.yml b/Resources/Prototypes/Maps/reach.yml index 03a688cf23..a0d6752c1f 100644 --- a/Resources/Prototypes/Maps/reach.yml +++ b/Resources/Prototypes/Maps/reach.yml @@ -16,8 +16,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency.yml - type: StationJobs - overflowJobs: - - Passenger availableJobs: Captain: [ 1, 1 ] HeadOfSecurity: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/saltern.yml b/Resources/Prototypes/Maps/saltern.yml index bc12eca654..e9d26ce319 100644 --- a/Resources/Prototypes/Maps/saltern.yml +++ b/Resources/Prototypes/Maps/saltern.yml @@ -15,8 +15,6 @@ !type:NanotrasenNameGenerator prefixCreator: '14' - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ] diff --git a/Resources/Prototypes/Maps/train.yml b/Resources/Prototypes/Maps/train.yml index b18db7aea8..7f24fcdd67 100644 --- a/Resources/Prototypes/Maps/train.yml +++ b/Resources/Prototypes/Maps/train.yml @@ -18,8 +18,6 @@ - type: StationEmergencyShuttle emergencyShuttlePath: /Maps/Shuttles/emergency_omega.yml # To do - add railway station - type: StationJobs - overflowJobs: - - Passenger availableJobs: #service Captain: [ 1, 1 ]