diff --git a/Content.Client/Buckle/BuckleSystem.cs b/Content.Client/Buckle/BuckleSystem.cs index fea18e5cf3..b899a88106 100644 --- a/Content.Client/Buckle/BuckleSystem.cs +++ b/Content.Client/Buckle/BuckleSystem.cs @@ -57,7 +57,6 @@ private void OnAppearanceChange(EntityUid uid, BuckleComponent component, ref Ap !buckled || args.Sprite == null) { - _rotationVisualizerSystem.SetHorizontalAngle((uid, rotVisuals), rotVisuals.DefaultRotation); return; } diff --git a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs index 9484606951..403161e0c6 100644 --- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs +++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs @@ -110,6 +110,12 @@ private void HandleStaticStorageUI(BaseButton.ButtonToggledEventArgs args) _cfg.SaveToFile(); } + private void HandleToggleAutoGetUp(BaseButton.ButtonToggledEventArgs args) // WD EDIT + { + _cfg.SetCVar(WhiteCVars.AutoGetUp, args.Pressed); + _cfg.SaveToFile(); + } + public KeyRebindTab() { IoCManager.InjectDependencies(this); @@ -194,6 +200,7 @@ void AddCheckBox(string checkBoxName, bool currentState, Action(OnMovementInput); + + SubscribeNetworkEvent(OnCheckAutoGetUp); + } + + private void OnMovementInput(EntityUid uid, LayingDownComponent component, MoveEvent args) + { + if (!_timing.IsFirstTimePredicted) + return; + + if (!_standing.IsDown(uid)) + return; + + if (_buckle.IsBuckled(uid)) + return; + + if (_animation.HasRunningAnimation(uid, "rotate")) + return; + + if (!TryComp(uid, out var transform) + || !TryComp(uid, out var sprite) + || !TryComp(uid, out var rotationVisuals)) + { + return; + } + + var rotation = transform.LocalRotation + (_eyeManager.CurrentEye.Rotation - (transform.LocalRotation - transform.WorldRotation)); + + if (rotation.GetDir() is Direction.SouthEast or Direction.East or Direction.NorthEast or Direction.North) + { + rotationVisuals.HorizontalRotation = Angle.FromDegrees(270); + sprite.Rotation = Angle.FromDegrees(270); + return; + } + + rotationVisuals.HorizontalRotation = Angle.FromDegrees(90); + sprite.Rotation = Angle.FromDegrees(90); + } + + private void OnCheckAutoGetUp(CheckAutoGetUpEvent ev, EntitySessionEventArgs args) + { + if (!_timing.IsFirstTimePredicted) + return; + + var uid = GetEntity(ev.User); + + if (!TryComp(uid, out var transform) || !TryComp(uid, out var rotationVisuals)) + return; + + var rotation = transform.LocalRotation + (_eyeManager.CurrentEye.Rotation - (transform.LocalRotation - transform.WorldRotation)); + + if (rotation.GetDir() is Direction.SouthEast or Direction.East or Direction.NorthEast or Direction.North) + { + rotationVisuals.HorizontalRotation = Angle.FromDegrees(270); + return; + } + + rotationVisuals.HorizontalRotation = Angle.FromDegrees(90); + } +} diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs index 0ea6d3e2dc..f46b83165f 100644 --- a/Content.IntegrationTests/Pair/TestPair.Helpers.cs +++ b/Content.IntegrationTests/Pair/TestPair.Helpers.cs @@ -2,6 +2,9 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Content.Server.Preferences.Managers; +using Content.Shared.Preferences; +using Content.Shared.Roles; using Robust.Shared.GameObjects; using Robust.Shared.Map; using Robust.Shared.Prototypes; @@ -128,4 +131,29 @@ public List GetPrototypesWithComponent( return list; } + + /// + /// Helper method for enabling or disabling a antag role + /// + public async Task SetAntagPref(ProtoId id, bool value) + { + var prefMan = Server.ResolveDependency(); + + var prefs = prefMan.GetPreferences(Client.User!.Value); + // what even is the point of ICharacterProfile if we always cast it to HumanoidCharacterProfile to make it usable? + var profile = (HumanoidCharacterProfile) prefs.SelectedCharacter; + + Assert.That(profile.AntagPreferences.Any(preference => preference == id), Is.EqualTo(!value)); + var newProfile = profile.WithAntagPreference(id, value); + + await Server.WaitPost(() => + { + prefMan.SetProfile(Client.User.Value, prefs.SelectedCharacterIndex, newProfile).Wait(); + }); + + // And why the fuck does it always create a new preference and profile object instead of just reusing them? + var newPrefs = prefMan.GetPreferences(Client.User.Value); + var newProf = (HumanoidCharacterProfile) newPrefs.SelectedCharacter; + Assert.That(newProf.AntagPreferences.Any(preference => preference == id), Is.EqualTo(value)); + } } diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs index b544fe2854..3f489de649 100644 --- a/Content.IntegrationTests/PoolManager.cs +++ b/Content.IntegrationTests/PoolManager.cs @@ -68,11 +68,11 @@ public static partial class PoolManager options.BeforeStart += () => { + // Server-only systems (i.e., systems that subscribe to events with server-only components) var entSysMan = IoCManager.Resolve(); - entSysMan.LoadExtraSystemType(); - entSysMan.LoadExtraSystemType(); entSysMan.LoadExtraSystemType(); entSysMan.LoadExtraSystemType(); + IoCManager.Resolve().GetSawmill("loc").Level = LogLevel.Error; IoCManager.Resolve() .OnValueChanged(RTCVars.FailureLogLevel, value => logHandler.FailureLevel = value, true); diff --git a/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs new file mode 100644 index 0000000000..662ea3b974 --- /dev/null +++ b/Content.IntegrationTests/Tests/GameRules/AntagPreferenceTest.cs @@ -0,0 +1,76 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Content.Server.Antag; +using Content.Server.Antag.Components; +using Content.Server.GameTicking; +using Content.Shared.GameTicking; +using Robust.Shared.GameObjects; +using Robust.Shared.Player; +using Robust.Shared.Random; + +namespace Content.IntegrationTests.Tests.GameRules; + +// Once upon a time, players in the lobby weren't ever considered eligible for antag roles. +// Lets not let that happen again. +[TestFixture] +public sealed class AntagPreferenceTest +{ + [Test] + public async Task TestLobbyPlayersValid() + { + await using var pair = await PoolManager.GetServerClient(new PoolSettings + { + DummyTicker = false, + Connected = true, + InLobby = true + }); + + var server = pair.Server; + var client = pair.Client; + var ticker = server.System(); + var sys = server.System(); + + // Initially in the lobby + Assert.That(ticker.RunLevel, Is.EqualTo(GameRunLevel.PreRoundLobby)); + Assert.That(client.AttachedEntity, Is.Null); + Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay)); + + EntityUid uid = default; + await server.WaitPost(() => uid = server.EntMan.Spawn("Traitor")); + var rule = new Entity(uid, server.EntMan.GetComponent(uid)); + var def = rule.Comp.Definitions.Single(); + + // IsSessionValid & IsEntityValid are preference agnostic and should always be true for players in the lobby. + // Though maybe that will change in the future, but then GetPlayerPool() needs to be updated to reflect that. + Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); + Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); + + // By default, traitor/antag preferences are disabled, so the pool should be empty. + var sessions = new List{pair.Player!}; + var pool = sys.GetPlayerPool(rule, sessions, def); + Assert.That(pool.Count, Is.EqualTo(0)); + + // Opt into the traitor role. + await pair.SetAntagPref("Traitor", true); + + Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); + Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); + pool = sys.GetPlayerPool(rule, sessions, def); + Assert.That(pool.Count, Is.EqualTo(1)); + pool.TryPickAndTake(pair.Server.ResolveDependency(), out var picked); + Assert.That(picked, Is.EqualTo(pair.Player)); + Assert.That(sessions.Count, Is.EqualTo(1)); + + // opt back out + await pair.SetAntagPref("Traitor", false); + + Assert.That(sys.IsSessionValid(rule, pair.Player, def), Is.True); + Assert.That(sys.IsEntityValid(client.AttachedEntity, def), Is.True); + pool = sys.GetPlayerPool(rule, sessions, def); + Assert.That(pool.Count, Is.EqualTo(0)); + + await server.WaitPost(() => server.EntMan.DeleteEntity(uid)); + await pair.CleanReturnAsync(); + } +} diff --git a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs index f539daee36..62fa93c999 100644 --- a/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs +++ b/Content.IntegrationTests/Tests/GameRules/NukeOpsTest.cs @@ -60,6 +60,9 @@ public async Task TryStopNukeOpsFromConstantlyFailing() Assert.That(client.AttachedEntity, Is.Null); Assert.That(ticker.PlayerGameStatuses[client.User!.Value], Is.EqualTo(PlayerGameStatus.NotReadyToPlay)); + // Opt into the nukies role. + await pair.SetAntagPref("NukeopsCommander", true); + // There are no grids or maps Assert.That(entMan.Count(), Is.Zero); Assert.That(entMan.Count(), Is.Zero); @@ -201,6 +204,7 @@ public async Task TryStopNukeOpsFromConstantlyFailing() //ticker.SetGamePreset((GamePresetPrototype?)null); WD edit server.CfgMan.SetCVar(CCVars.GridFill, false); + await pair.SetAntagPref("NukeopsCommander", false); await pair.CleanReturnAsync(); } } diff --git a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs index 317aa10400..4415eddf37 100644 --- a/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs +++ b/Content.IntegrationTests/Tests/Interaction/Click/InteractionSystemTests.cs @@ -407,7 +407,6 @@ await server.WaitAssertion(() => await pair.CleanReturnAsync(); } - [Reflect(false)] public sealed class TestInteractionSystem : EntitySystem { public EntityEventHandler? InteractUsingEvent; diff --git a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs index d5c2a9124d..40457f5488 100644 --- a/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs +++ b/Content.IntegrationTests/Tests/ResettingEntitySystemTests.cs @@ -9,7 +9,6 @@ namespace Content.IntegrationTests.Tests [TestOf(typeof(RoundRestartCleanupEvent))] public sealed class ResettingEntitySystemTests { - [Reflect(false)] public sealed class TestRoundRestartCleanupEvent : EntitySystem { public bool HasBeenReset { get; set; } @@ -49,8 +48,6 @@ await server.WaitAssertion(() => system.HasBeenReset = false; - Assert.That(system.HasBeenReset, Is.False); - gameTicker.RestartRound(); Assert.That(system.HasBeenReset); diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs index df77a3a1a7..4103b8a8aa 100644 --- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs +++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs @@ -44,9 +44,11 @@ private void AddAntagVerbs(GetVerbsEvent args) if (!_adminManager.HasAdminFlag(player, AdminFlags.Fun)) return; - if (!HasComp(args.Target)) + if (!HasComp(args.Target) || !TryComp(args.Target, out var targetActor)) return; + var targetPlayer = targetActor.PlayerSession; + Verb traitor = new() { Text = Loc.GetString("admin-verb-text-make-traitor"), @@ -54,7 +56,7 @@ private void AddAntagVerbs(GetVerbsEvent args) Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Structures/Wallmounts/posters.rsi"), "poster5_contraband"), Act = () => { - _antag.ForceMakeAntag(player, DefaultTraitorRule); + _antag.ForceMakeAntag(targetPlayer, DefaultTraitorRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-traitor"), @@ -83,7 +85,7 @@ private void AddAntagVerbs(GetVerbsEvent args) Icon = new SpriteSpecifier.Rsi(new("/Textures/Structures/Wallmounts/signs.rsi"), "radiation"), Act = () => { - _antag.ForceMakeAntag(player, DefaultNukeOpRule); + _antag.ForceMakeAntag(targetPlayer, DefaultNukeOpRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-nuclear-operative"), @@ -112,7 +114,7 @@ private void AddAntagVerbs(GetVerbsEvent args) Icon = new SpriteSpecifier.Rsi(new("/Textures/Interface/Misc/job_icons.rsi"), "HeadRevolutionary"), Act = () => { - _antag.ForceMakeAntag(player, DefaultRevsRule); + _antag.ForceMakeAntag(targetPlayer, DefaultRevsRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-head-rev"), @@ -123,10 +125,10 @@ private void AddAntagVerbs(GetVerbsEvent args) { Text = Loc.GetString("admin-verb-text-make-thief"), Category = VerbCategory.Antag, - Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Clothing/Hands/Gloves/ihscombat.rsi"), "icon"), + Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Clothing/Hands/Gloves/Color/black.rsi"), "icon"), Act = () => { - _antag.ForceMakeAntag(player, DefaultThiefRule); + _antag.ForceMakeAntag(targetPlayer, DefaultThiefRule); }, Impact = LogImpact.High, Message = Loc.GetString("admin-verb-make-thief"), diff --git a/Content.Server/Antag/AntagSelectionSystem.API.cs b/Content.Server/Antag/AntagSelectionSystem.API.cs index 470f98fca1..59bf05fe03 100644 --- a/Content.Server/Antag/AntagSelectionSystem.API.cs +++ b/Content.Server/Antag/AntagSelectionSystem.API.cs @@ -7,6 +7,7 @@ using Content.Shared.Mind; using JetBrains.Annotations; using Robust.Shared.Audio; +using Robust.Shared.Enums; using Robust.Shared.Player; namespace Content.Server.Antag; @@ -26,6 +27,11 @@ public bool TryGetNextAvailableDefinition(Entity ent, if (mindCount >= totalTargetCount) return false; + // TODO ANTAG fix this + // If here are two definitions with 1/10 and 10/10 slots filled, this will always return the second definition + // even though it has already met its target + // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA I fucking hate game ticker code. + // It needs to track selected minds for each definition independently. foreach (var def in ent.Comp.Definitions) { var target = GetTargetAntagCount(ent, null, def); @@ -46,12 +52,26 @@ public bool TryGetNextAvailableDefinition(Entity ent, /// Gets the number of antagonists that should be present for a given rule based on the provided pool. /// A null pool will simply use the player count. /// - public int GetTargetAntagCount(Entity ent, AntagSelectionPlayerPool? pool = null) + public int GetTargetAntagCount(Entity ent, int? playerCount = null) { var count = 0; foreach (var def in ent.Comp.Definitions) { - count += GetTargetAntagCount(ent, pool, def); + count += GetTargetAntagCount(ent, playerCount, def); + } + + return count; + } + + public int GetTotalPlayerCount(IList pool) + { + var count = 0; + foreach (var session in pool) + { + if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie) + continue; + + count++; } return count; @@ -61,9 +81,14 @@ public int GetTargetAntagCount(Entity ent, AntagSelecti /// Gets the number of antagonists that should be present for a given antag definition based on the provided pool. /// A null pool will simply use the player count. /// - public int GetTargetAntagCount(Entity ent, AntagSelectionPlayerPool? pool, AntagSelectionDefinition def) + public int GetTargetAntagCount(Entity ent, int? playerCount, AntagSelectionDefinition def) { - var poolSize = pool?.Count ?? _playerManager.Sessions.Length; + // TODO ANTAG + // make pool non-nullable + // Review uses and ensure that people are INTENTIONALLY including players in the lobby if this is a mid-round + // antag selection. + var poolSize = playerCount ?? GetTotalPlayerCount(_playerManager.Sessions); + // factor in other definitions' affect on the count. var countOffset = 0; foreach (var otherDef in ent.Comp.Definitions) @@ -278,7 +303,7 @@ public void ForceMakeAntag(ICommonSession? player, string defaultRule) where if (!TryGetNextAvailableDefinition(rule, out var def)) def = rule.Comp.Definitions.Last(); - + MakeAntag(rule, player, def.Value); } diff --git a/Content.Server/Antag/AntagSelectionSystem.cs b/Content.Server/Antag/AntagSelectionSystem.cs index 6bfb7394f5..d74824dd2d 100644 --- a/Content.Server/Antag/AntagSelectionSystem.cs +++ b/Content.Server/Antag/AntagSelectionSystem.cs @@ -13,6 +13,7 @@ using Content.Server.Shuttles.Components; using Content.Server.Station.Systems; using Content.Shared.Antag; +using Content.Shared.GameTicking; using Content.Shared.Ghost; using Content.Shared.Humanoid; using Content.Shared.Players; @@ -24,6 +25,7 @@ using Robust.Shared.Map; using Robust.Shared.Player; using Robust.Shared.Random; +using Robust.Shared.Utility; namespace Content.Server.Antag; @@ -82,10 +84,9 @@ private void OnPlayerSpawning(RulePlayerSpawningEvent args) continue; if (comp.SelectionsComplete) - return; + continue; ChooseAntags((uid, comp), pool); - comp.SelectionsComplete = true; foreach (var session in comp.SelectedSessions) { @@ -103,11 +104,7 @@ private void OnJobsAssigned(RulePlayerJobsAssignedEvent args) if (comp.SelectionTime != AntagSelectionTime.PostPlayerSpawn) continue; - if (comp.SelectionsComplete) - continue; - - ChooseAntags((uid, comp)); - comp.SelectionsComplete = true; + ChooseAntags((uid, comp), args.Players); } } @@ -123,12 +120,18 @@ private void OnSpawnComplete(PlayerSpawnCompleteEvent args) var query = QueryActiveRules(); while (query.MoveNext(out var uid, out _, out var antag, out _)) { + // TODO ANTAG + // what why aasdiuhasdopiuasdfhksad + // stop this insanity please + // probability of antag assignment shouldn't depend on the order in which rules are returned by the query. if (!RobustRandom.Prob(LateJoinRandomChance)) continue; if (!antag.Definitions.Any(p => p.LateJoinAdditional)) continue; + DebugTools.AssertEqual(antag.SelectionTime, AntagSelectionTime.PostPlayerSpawn); + if (!TryGetNextAvailableDefinition((uid, antag), out var def)) continue; @@ -161,46 +164,43 @@ protected override void Started(EntityUid uid, AntagSelectionComponent component { base.Started(uid, component, gameRule, args); - if (component.SelectionsComplete) - return; - + // If the round has not yet started, we defer antag selection until roundstart if (GameTicker.RunLevel != GameRunLevel.InRound) return; - if (GameTicker.RunLevel == GameRunLevel.InRound && component.SelectionTime == AntagSelectionTime.PrePlayerSpawn) + if (component.SelectionsComplete) return; - ChooseAntags((uid, component)); - component.SelectionsComplete = true; - } + var players = _playerManager.Sessions + .Where(x => GameTicker.PlayerGameStatuses[x.UserId] == PlayerGameStatus.JoinedGame) + .ToList(); - /// - /// Chooses antagonists from the current selection of players - /// - public void ChooseAntags(Entity ent) - { - var sessions = _playerManager.Sessions.ToList(); - ChooseAntags(ent, sessions); + ChooseAntags((uid, component), players); } /// /// Chooses antagonists from the given selection of players /// - public void ChooseAntags(Entity ent, List pool) + public void ChooseAntags(Entity ent, IList pool) { + if (ent.Comp.SelectionsComplete) + return; + foreach (var def in ent.Comp.Definitions) { ChooseAntags(ent, pool, def); } + + ent.Comp.SelectionsComplete = true; } /// /// Chooses antagonists from the given selection of players for the given antag definition. /// - public void ChooseAntags(Entity ent, List pool, AntagSelectionDefinition def) + public void ChooseAntags(Entity ent, IList pool, AntagSelectionDefinition def) { var playerPool = GetPlayerPool(ent, pool, def); - var count = GetTargetAntagCount(ent, playerPool, def); + var count = GetTargetAntagCount(ent, GetTotalPlayerCount(pool), def); for (var i = 0; i < count; i++) { @@ -308,11 +308,7 @@ public void MakeAntag(Entity ent, ICommonSession? sessi _mind.TransferTo(curMind.Value, antagEnt, ghostCheckOverride: true); _role.MindAddRoles(curMind.Value, def.MindComponents); ent.Comp.SelectedMinds.Add((curMind.Value, Name(player))); - } - - if (def.Briefing is { } briefing) - { - SendBriefing(session, briefing); + SendBriefing(session, def.Briefing); } var afterEv = new AfterAntagEntitySelectedEvent(session, player, ent, def); @@ -322,20 +318,15 @@ public void MakeAntag(Entity ent, ICommonSession? sessi /// /// Gets an ordered player pool based on player preferences and the antagonist definition. /// - public AntagSelectionPlayerPool GetPlayerPool(Entity ent, List sessions, AntagSelectionDefinition def) + public AntagSelectionPlayerPool GetPlayerPool(Entity ent, IList sessions, AntagSelectionDefinition def) { var preferredList = new List(); - var secondBestList = new List(); - var unwantedList = new List(); - var invalidList = new List(); + var fallbackList = new List(); foreach (var session in sessions) { if (!IsSessionValid(ent, session, def) || !IsEntityValid(session.AttachedEntity, def)) - { - invalidList.Add(session); continue; - } var pref = (HumanoidCharacterProfile) _pref.GetPreferences(session.UserId).SelectedCharacter; if (def.PrefRoles.Count != 0 && pref.AntagPreferences.Any(p => def.PrefRoles.Contains(p))) @@ -344,15 +335,11 @@ public AntagSelectionPlayerPool GetPlayerPool(Entity en } else if (def.FallbackRoles.Count != 0 && pref.AntagPreferences.Any(p => def.FallbackRoles.Contains(p))) { - secondBestList.Add(session); - } - else - { - unwantedList.Add(session); + fallbackList.Add(session); } } - return new AntagSelectionPlayerPool(new() { preferredList, secondBestList, unwantedList, invalidList }); + return new AntagSelectionPlayerPool(new() { preferredList, fallbackList }); } /// @@ -363,14 +350,18 @@ public bool IsSessionValid(Entity ent, ICommonSession? if (session == null) return true; - mind ??= session.GetMind(); - if (session.Status is SessionStatus.Disconnected or SessionStatus.Zombie) return false; if (ent.Comp.SelectedSessions.Contains(session)) return false; + mind ??= session.GetMind(); + + // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity. + if (mind == null) + return true; + //todo: we need some way to check that we're not getting the same role twice. (double picking thieves or zombies through midrounds) switch (def.MultiAntagSetting) @@ -399,10 +390,11 @@ public bool IsSessionValid(Entity ent, ICommonSession? /// /// Checks if a given entity (mind/session not included) is valid for a given antagonist. /// - private bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def) + public bool IsEntityValid(EntityUid? entity, AntagSelectionDefinition def) { + // If the player has not spawned in as any entity (e.g., in the lobby), they can be given an antag role/entity. if (entity == null) - return false; + return true; if (HasComp(entity)) return false; diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 154244ace1..0fc984abed 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -175,6 +175,26 @@ public IReadOnlyList LoadGameMap(GameMapPrototype map, MapId targetMa return gridUids; } + public int ReadyPlayerCount() + { + var total = 0; + foreach (var (userId, status) in _playerGameStatuses) + { + if (LobbyEnabled && status == PlayerGameStatus.NotReadyToPlay) + continue; + + if (!_playerManager.TryGetSessionById(userId, out _)) + continue; + + if (_banManager.GetRoleBans(userId) == null) + continue; + + total++; + } + + return total; + } + public void StartRound(bool force = false) { #if EXCEPTION_TOLERANCE @@ -230,6 +250,8 @@ public void StartRound(bool force = false) readyPlayerProfiles.Add(userId, profile); } + DebugTools.AssertEqual(readyPlayers.Count, ReadyPlayerCount()); + // Just in case it hasn't been loaded previously we'll try loading it. LoadMaps(); @@ -771,7 +793,7 @@ public RulePlayerSpawningEvent(List playerPool, IReadOnlyDiction } /// - /// Event raised after players were assigned jobs by the GameTicker. + /// Event raised after players were assigned jobs by the GameTicker and have been spawned in. /// You can give on-station people special roles by listening to this event. /// public sealed class RulePlayerJobsAssignedEvent diff --git a/Content.Server/GameTicking/Rules/SecretRuleSystem.cs b/Content.Server/GameTicking/Rules/SecretRuleSystem.cs index d5adb8fdb7..95bf5986a5 100644 --- a/Content.Server/GameTicking/Rules/SecretRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/SecretRuleSystem.cs @@ -1,15 +1,17 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Content.Server.Administration.Logs; using Content.Server.GameTicking.Components; using Content.Server.Chat.Managers; using Content.Server.GameTicking.Presets; using Content.Server.GameTicking.Rules.Components; using Content.Shared.Random; -using Content.Shared.Random.Helpers; using Content.Shared.CCVar; using Content.Shared.Database; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Configuration; +using Robust.Shared.Utility; namespace Content.Server.GameTicking.Rules; @@ -20,11 +22,46 @@ public sealed class SecretRuleSystem : GameRuleSystem [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IChatManager _chatManager = default!; + [Dependency] private readonly IComponentFactory _compFact = default!; + + private string _ruleCompName = default!; + + public override void Initialize() + { + base.Initialize(); + _ruleCompName = _compFact.GetComponentName(typeof(GameRuleComponent)); + } protected override void Added(EntityUid uid, SecretRuleComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args) { base.Added(uid, component, gameRule, args); - PickRule(component); + var weights = _configurationManager.GetCVar(CCVars.SecretWeightPrototype); + + if (!TryPickPreset(weights, out var preset)) + { + Log.Error($"{ToPrettyString(uid)} failed to pick any preset. Removing rule."); + Del(uid); + return; + } + + Log.Info($"Selected {preset.ID} as the secret preset."); + _adminLogger.Add(LogType.EventStarted, $"Selected {preset.ID} as the secret preset."); + _chatManager.SendAdminAnnouncement(Loc.GetString("rule-secret-selected-preset", ("preset", preset.ID))); + + foreach (var rule in preset.Rules) + { + EntityUid ruleEnt; + + // if we're pre-round (i.e. will only be added) + // then just add rules. if we're added in the middle of the round (or at any other point really) + // then we want to start them as well + if (GameTicker.RunLevel <= GameRunLevel.InRound) + ruleEnt = GameTicker.AddGameRule(rule); + else + GameTicker.StartGameRule(rule, out ruleEnt); + + component.AdditionalGameRules.Add(ruleEnt); + } } protected override void Ended(EntityUid uid, SecretRuleComponent component, GameRuleComponent gameRule, GameRuleEndedEvent args) @@ -37,32 +74,101 @@ protected override void Ended(EntityUid uid, SecretRuleComponent component, Game } } - private void PickRule(SecretRuleComponent component) + private bool TryPickPreset(ProtoId weights, [NotNullWhen(true)] out GamePresetPrototype? preset) { - // TODO: This doesn't consider what can't start due to minimum player count, - // but currently there's no way to know anyway as they use cvars. - var presetString = _configurationManager.GetCVar(CCVars.SecretWeightPrototype); - var preset = _prototypeManager.Index(presetString).Pick(_random); - Log.Info($"Selected {preset} for secret."); - _adminLogger.Add(LogType.EventStarted, $"Selected {preset} for secret."); - _chatManager.SendAdminAnnouncement(Loc.GetString("rule-secret-selected-preset", ("preset", preset))); - - var rules = _prototypeManager.Index(preset).Rules; - foreach (var rule in rules) + var options = _prototypeManager.Index(weights).Weights.ShallowClone(); + var players = GameTicker.ReadyPlayerCount(); + + GamePresetPrototype? selectedPreset = null; + var sum = options.Values.Sum(); + while (options.Count > 0) { - EntityUid ruleEnt; + var accumulated = 0f; + var rand = _random.NextFloat(sum); + foreach (var (key, weight) in options) + { + accumulated += weight; + if (accumulated < rand) + continue; - // if we're pre-round (i.e. will only be added) - // then just add rules. if we're added in the middle of the round (or at any other point really) - // then we want to start them as well - if (GameTicker.RunLevel <= GameRunLevel.InRound) - ruleEnt = GameTicker.AddGameRule(rule); - else + if (!_prototypeManager.TryIndex(key, out selectedPreset)) + Log.Error($"Invalid preset {selectedPreset} in secret rule weights: {weights}"); + + options.Remove(key); + sum -= weight; + break; + } + + if (CanPick(selectedPreset, players)) { - GameTicker.StartGameRule(rule, out ruleEnt); + preset = selectedPreset; + return true; } - component.AdditionalGameRules.Add(ruleEnt); + if (selectedPreset != null) + Log.Info($"Excluding {selectedPreset.ID} from secret preset selection."); + } + + preset = null; + return false; + } + + public bool CanPickAny() + { + var secretPresetId = _configurationManager.GetCVar(CCVars.SecretWeightPrototype); + return CanPickAny(secretPresetId); + } + + /// + /// Can any of the given presets be picked, taking into account the currently available player count? + /// + public bool CanPickAny(ProtoId weightedPresets) + { + var ids = _prototypeManager.Index(weightedPresets).Weights.Keys + .Select(x => new ProtoId(x)); + + return CanPickAny(ids); + } + + /// + /// Can any of the given presets be picked, taking into account the currently available player count? + /// + public bool CanPickAny(IEnumerable> protos) + { + var players = GameTicker.ReadyPlayerCount(); + foreach (var id in protos) + { + if (!_prototypeManager.TryIndex(id, out var selectedPreset)) + Log.Error($"Invalid preset {selectedPreset} in secret rule weights: {id}"); + + if (CanPick(selectedPreset, players)) + return true; } + + return false; + } + + /// + /// Can the given preset be picked, taking into account the currently available player count? + /// + private bool CanPick([NotNullWhen(true)] GamePresetPrototype? selected, int players) + { + if (selected == null) + return false; + + foreach (var ruleId in selected.Rules) + { + if (!_prototypeManager.TryIndex(ruleId, out EntityPrototype? rule) + || !rule.TryGetComponent(_ruleCompName, out GameRuleComponent? ruleComp)) + { + Log.Error($"Encountered invalid rule {ruleId} in preset {selected.ID}"); + return false; + } + + if (ruleComp.MinPlayers > players) + return false; + } + + return true; } } diff --git a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs index 1808592ef5..f26c998495 100644 --- a/Content.Server/Preferences/Managers/IServerPreferencesManager.cs +++ b/Content.Server/Preferences/Managers/IServerPreferencesManager.cs @@ -19,5 +19,7 @@ public interface IServerPreferencesManager PlayerPreferences? GetPreferencesOrNull(NetUserId? userId); IEnumerable> GetSelectedProfilesForPlayers(List userIds); bool HavePreferencesLoaded(ICommonSession session); + + Task SetProfile(NetUserId userId, int slot, ICharacterProfile profile); } } diff --git a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs index c3efe14be9..95eb9591fb 100644 --- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs +++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs @@ -3,17 +3,12 @@ using System.Threading; using System.Threading.Tasks; using Content.Server.Database; -using Content.Server.Humanoid; using Content.Shared.CCVar; -using Content.Shared.Humanoid.Prototypes; using Content.Shared.Preferences; -using Content.Shared.Roles; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; -using Robust.Shared.Prototypes; - namespace Content.Server.Preferences.Managers { @@ -27,12 +22,14 @@ public sealed class ServerPreferencesManager : IServerPreferencesManager [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IServerDbManager _db = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly IPrototypeManager _protos = default!; + [Dependency] private readonly ILogManager _log = default!; // Cache player prefs on the server so we don't need as much async hell related to them. private readonly Dictionary _cachedPlayerPrefs = new(); + private ISawmill _sawmill = default!; + private int MaxCharacterSlots => _cfg.GetCVar(CCVars.GameMaxCharacterSlots); public void Init() @@ -41,6 +38,7 @@ public void Init() _netManager.RegisterNetMessage(HandleSelectCharacterMessage); _netManager.RegisterNetMessage(HandleUpdateCharacterMessage); _netManager.RegisterNetMessage(HandleDeleteCharacterMessage); + _sawmill = _log.GetSawmill("prefs"); } private async void HandleSelectCharacterMessage(MsgSelectCharacter message) @@ -77,27 +75,25 @@ private async void HandleSelectCharacterMessage(MsgSelectCharacter message) private async void HandleUpdateCharacterMessage(MsgUpdateCharacter message) { - var slot = message.Slot; - var profile = message.Profile; var userId = message.MsgChannel.UserId; - if (profile == null) - { - Logger.WarningS("prefs", - $"User {userId} sent a {nameof(MsgUpdateCharacter)} with a null profile in slot {slot}."); - return; - } + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (message.Profile == null) + _sawmill.Error($"User {userId} sent a {nameof(MsgUpdateCharacter)} with a null profile in slot {message.Slot}."); + else + await SetProfile(userId, message.Slot, message.Profile); + } + public async Task SetProfile(NetUserId userId, int slot, ICharacterProfile profile) + { if (!_cachedPlayerPrefs.TryGetValue(userId, out var prefsData) || !prefsData.PrefsLoaded) { - Logger.WarningS("prefs", $"User {userId} tried to modify preferences before they loaded."); + _sawmill.Error($"Tried to modify user {userId} preferences before they loaded."); return; } if (slot < 0 || slot >= MaxCharacterSlots) - { return; - } var curPrefs = prefsData.Prefs!; var session = _playerManager.GetSessionById(userId); @@ -112,10 +108,8 @@ private async void HandleUpdateCharacterMessage(MsgUpdateCharacter message) prefsData.Prefs = new PlayerPreferences(profiles, slot, curPrefs.AdminOOCColor); - if (ShouldStorePrefs(message.MsgChannel.AuthType)) - { - await _db.SaveCharacterSlotAsync(message.MsgChannel.UserId, message.Profile, message.Slot); - } + if (ShouldStorePrefs(session.Channel.AuthType)) + await _db.SaveCharacterSlotAsync(userId, profile, slot); } private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message) @@ -142,7 +136,7 @@ private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message) if (curPrefs.SelectedCharacterIndex == slot) { // That ! on the end is because Rider doesn't like .NET 5. - var (ns, profile) = curPrefs.Characters.FirstOrDefault(p => p.Key != message.Slot)!; + var (ns, profile) = curPrefs.Characters.FirstOrDefault(p => p.Key != message.Slot); if (profile == null) { // Only slot left, can't delete. @@ -157,16 +151,18 @@ private async void HandleDeleteCharacterMessage(MsgDeleteCharacter message) prefsData.Prefs = new PlayerPreferences(arr, nextSlot ?? curPrefs.SelectedCharacterIndex, curPrefs.AdminOOCColor); - if (ShouldStorePrefs(message.MsgChannel.AuthType)) + if (!ShouldStorePrefs(message.MsgChannel.AuthType)) { - if (nextSlot != null) - { - await _db.DeleteSlotAndSetSelectedIndex(userId, slot, nextSlot.Value); - } - else - { - await _db.SaveCharacterSlotAsync(userId, null, slot); - } + return; + } + + if (nextSlot != null) + { + await _db.DeleteSlotAndSetSelectedIndex(userId, slot, nextSlot.Value); + } + else + { + await _db.SaveCharacterSlotAsync(userId, null, slot); } } @@ -200,11 +196,13 @@ async Task LoadPrefs() prefsData.Prefs = prefs; prefsData.PrefsLoaded = true; - var msg = new MsgPreferencesAndSettings(); - msg.Preferences = prefs; - msg.Settings = new GameSettings + var msg = new MsgPreferencesAndSettings { - MaxCharacterSlots = MaxCharacterSlots + Preferences = prefs, + Settings = new GameSettings + { + MaxCharacterSlots = MaxCharacterSlots + } }; _netManager.ServerSendMessage(msg, session.Channel); } @@ -221,7 +219,6 @@ public bool HavePreferencesLoaded(ICommonSession session) return _cachedPlayerPrefs.ContainsKey(session.UserId); } - /// /// Tries to get the preferences from the cache /// @@ -289,10 +286,8 @@ private PlayerPreferences SanitizePreferences(ICommonSession session, PlayerPref { // Clean up preferences in case of changes to the game, // such as removed jobs still being selected. - return new PlayerPreferences(prefs.Characters.Select(p => - { - return new KeyValuePair(p.Key, p.Value.Validated(session, collection)); - }), prefs.SelectedCharacterIndex, prefs.AdminOOCColor); + return new PlayerPreferences(prefs.Characters.Select(p => new KeyValuePair(p.Key, + p.Value.Validated(session, collection))), prefs.SelectedCharacterIndex, prefs.AdminOOCColor); } public IEnumerable> GetSelectedProfilesForPlayers( diff --git a/Content.Server/Standing/LayingDownComponent.cs b/Content.Server/Standing/LayingDownComponent.cs deleted file mode 100644 index 7921749f14..0000000000 --- a/Content.Server/Standing/LayingDownComponent.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Content.Server.Standing; - -[RegisterComponent] -public sealed partial class LayingDownComponent : Component -{ - [DataField] - public float DownedSpeedMultiplier = 0.15f; - - [DataField] - public TimeSpan Cooldown = TimeSpan.FromSeconds(2.5f); - - [DataField] - public TimeSpan NextToggleAttempt = TimeSpan.Zero; -} diff --git a/Content.Server/Standing/LayingDownSystem.cs b/Content.Server/Standing/LayingDownSystem.cs index 73a929fdfc..eccbf0773b 100644 --- a/Content.Server/Standing/LayingDownSystem.cs +++ b/Content.Server/Standing/LayingDownSystem.cs @@ -1,101 +1,28 @@ -using Content.Shared.ActionBlocker; -using Content.Shared.Input; -using Content.Shared.Movement.Systems; -using Content.Shared.Popups; -using Content.Shared.Standing; -using Robust.Shared.Input.Binding; -using Robust.Shared.Player; -using Robust.Shared.Timing; +using Content.Shared._White; +using Content.Shared._White.Standing; +using Robust.Shared.Configuration; namespace Content.Server.Standing; -/// Unfortunately cannot be shared because some standing conditions are server-side only -public sealed class LayingDownSystem : EntitySystem +public sealed class LayingDownSystem : SharedLayingDownSystem // WD EDIT { - [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; - [Dependency] private readonly MovementSpeedModifierSystem _movement = default!; - [Dependency] private readonly SharedPopupSystem _popups = default!; - [Dependency] private readonly Shared.Standing.StandingStateSystem _standing = default!; // WHY IS THERE TWO DIFFERENT STANDING SYSTEMS?! - [Dependency] private readonly IGameTiming _timing = default!; - + [Dependency] private readonly INetConfigurationManager _cfg = default!; public override void Initialize() { - CommandBinds.Builder - .Bind(ContentKeyFunctions.ToggleStanding, InputCmdHandler.FromDelegate(ToggleStanding, handle: false, outsidePrediction: false)) - .Register(); - - SubscribeLocalEvent(DoRefreshMovementSpeed); - SubscribeLocalEvent(DoRefreshMovementSpeed); - SubscribeLocalEvent(OnRefreshMovementSpeed); - SubscribeLocalEvent(OnParentChanged); - } - - public override void Shutdown() - { - base.Shutdown(); - - CommandBinds.Unregister(); - } + base.Initialize(); - private void DoRefreshMovementSpeed(EntityUid uid, LayingDownComponent component, object args) - { - _movement.RefreshMovementSpeedModifiers(uid); + SubscribeNetworkEvent(OnCheckAutoGetUp); } - private void OnRefreshMovementSpeed(EntityUid uid, LayingDownComponent component, RefreshMovementSpeedModifiersEvent args) + private void OnCheckAutoGetUp(CheckAutoGetUpEvent ev, EntitySessionEventArgs args) { - if (TryComp(uid, out var standingState) && standingState.Standing) - return; + var uid = GetEntity(ev.User); - args.ModifySpeed(component.DownedSpeedMultiplier, component.DownedSpeedMultiplier, bypassImmunity: true); - } - - private void OnParentChanged(EntityUid uid, LayingDownComponent component, EntParentChangedMessage args) - { - // If the entity is not on a grid, try to make it stand up to avoid issues - if (!TryComp(uid, out var standingState) - || standingState.Standing - || Transform(uid).GridUid != null) + if (!TryComp(uid, out LayingDownComponent? layingDown)) return; - _standing.Stand(uid, standingState); - } - - private void ToggleStanding(ICommonSession? session) - { - if (session is not { AttachedEntity: { Valid: true } uid } playerSession - || !Exists(uid) - || !TryComp(uid, out var standingState) - || !TryComp(uid, out var layingDown)) - return; - - // If successful, show popup to self and others. Otherwise, only to self. - if (ToggleStandingImpl(uid, standingState, layingDown, out var popupBranch)) - { - _popups.PopupEntity(Loc.GetString($"laying-comp-{popupBranch}-other", ("entity", uid)), uid, Filter.PvsExcept(uid), true); - layingDown.NextToggleAttempt = _timing.CurTime + layingDown.Cooldown; - } - - _popups.PopupEntity(Loc.GetString($"laying-comp-{popupBranch}-self", ("entity", uid)), uid, uid); - } - - private bool ToggleStandingImpl(EntityUid uid, StandingStateComponent standingState, LayingDownComponent layingDown, out string popupBranch) - { - var success = layingDown.NextToggleAttempt <= _timing.CurTime; - - if (_standing.IsDown(uid, standingState)) - { - success = success && _standing.Stand(uid, standingState, force: false); - popupBranch = success ? "stand-success" : "stand-fail"; - } - else - { - success = success && Transform(uid).GridUid != null; // Do not allow laying down when not on a surface. - success = success && _standing.Down(uid, standingState: standingState, playSound: true, dropHeldItems: false); - popupBranch = success ? "lay-success" : "lay-fail"; - } - - return success; + layingDown.AutoGetUp = _cfg.GetClientCVar(args.SenderSession.Channel, WhiteCVars.AutoGetUp); + Dirty(uid, layingDown); } } diff --git a/Content.Server/Traits/Assorted/LayingDownModifierSystem.cs b/Content.Server/Traits/Assorted/LayingDownModifierSystem.cs index dc6dcd2de3..7d2c16e3c4 100644 --- a/Content.Server/Traits/Assorted/LayingDownModifierSystem.cs +++ b/Content.Server/Traits/Assorted/LayingDownModifierSystem.cs @@ -1,5 +1,5 @@ using Content.Server.Traits.Assorted; -using Content.Server.Standing; +using Content.Shared._White.Standing; namespace Content.Shared.Traits.Assorted.Systems; @@ -16,7 +16,7 @@ private void OnStartup(EntityUid uid, LayingDownModifierComponent component, Com if (!TryComp(uid, out var layingDown)) return; - layingDown.Cooldown *= component.LayingDownCooldownMultiplier; - layingDown.DownedSpeedMultiplier *= component.DownedSpeedMultiplierMultiplier; + layingDown.StandingUpTime *= component.LayingDownCooldownMultiplier; + layingDown.SpeedModify *= component.DownedSpeedMultiplierMultiplier; } } diff --git a/Content.Shared/Antag/AntagAcceptability.cs b/Content.Shared/Antag/AntagAcceptability.cs index 02d0b5f58f..f56be97503 100644 --- a/Content.Shared/Antag/AntagAcceptability.cs +++ b/Content.Shared/Antag/AntagAcceptability.cs @@ -22,6 +22,14 @@ public enum AntagAcceptability public enum AntagSelectionTime : byte { + /// + /// Antag roles are assigned before players are assigned jobs and spawned in. + /// This prevents antag selection from happening if the round is on-going. + /// PrePlayerSpawn, + + /// + /// Antag roles get assigned after players have been assigned jobs and have spawned in. + /// PostPlayerSpawn } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index e419a67f16..9abe53210c 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -400,7 +400,7 @@ public static readonly CVarDef /// When true, you have to press the change speed button to sprint. /// public static readonly CVarDef GamePressToSprint = - CVarDef.Create("game.press_to_sprint", true, CVar.REPLICATED); + CVarDef.Create("game.press_to_sprint", false, CVar.REPLICATED); #if EXCEPTION_TOLERANCE /// diff --git a/Content.Shared/Roles/Jobs/SharedJobSystem.cs b/Content.Shared/Roles/Jobs/SharedJobSystem.cs index 04ac45c4c5..fcf7605278 100644 --- a/Content.Shared/Roles/Jobs/SharedJobSystem.cs +++ b/Content.Shared/Roles/Jobs/SharedJobSystem.cs @@ -146,8 +146,10 @@ public string MindTryGetJobName([NotNullWhen(true)] EntityUid? mindId) public bool CanBeAntag(ICommonSession player) { + // If the player does not have any mind associated with them (e.g., has not spawned in or is in the lobby), then + // they are eligible to be given an antag role/entity. if (_playerSystem.ContentData(player) is not { Mind: { } mindId }) - return false; + return true; if (!MindTryGetJob(mindId, out _, out var prototype)) return true; diff --git a/Content.Shared/Standing/StandingStateComponent.cs b/Content.Shared/Standing/StandingStateComponent.cs index 5d7bb0a59f..3a7cb2a008 100644 --- a/Content.Shared/Standing/StandingStateComponent.cs +++ b/Content.Shared/Standing/StandingStateComponent.cs @@ -1,24 +1,35 @@ using Robust.Shared.Audio; using Robust.Shared.GameStates; -namespace Content.Shared.Standing +namespace Content.Shared.Standing; + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class StandingStateComponent : Component { - [RegisterComponent, NetworkedComponent, AutoGenerateComponentState] - [Access(typeof(StandingStateSystem))] - public sealed partial class StandingStateComponent : Component - { - [ViewVariables(VVAccess.ReadWrite)] - [DataField] - public SoundSpecifier DownSound { get; private set; } = new SoundCollectionSpecifier("BodyFall"); + [ViewVariables(VVAccess.ReadWrite)] + [DataField] + public SoundSpecifier DownSound { get; private set; } = new SoundCollectionSpecifier("BodyFall"); + + // WD EDIT START + [DataField, AutoNetworkedField] + public StandingState CurrentState { get; set; } = StandingState.Standing; + // WD EDIT END - [DataField, AutoNetworkedField] - public bool Standing { get; set; } = true; + [DataField, AutoNetworkedField] + public bool Standing { get; set; } = true; - /// - /// List of fixtures that had their collision mask changed when the entity was downed. - /// Required for re-adding the collision mask. - /// - [DataField, AutoNetworkedField] - public List ChangedFixtures = new(); - } + /// + /// List of fixtures that had their collision mask changed when the entity was downed. + /// Required for re-adding the collision mask. + /// + [DataField, AutoNetworkedField] + public List ChangedFixtures = new(); +} +// WD EDIT START +public enum StandingState +{ + Lying, + GettingUp, + Standing, } +// WD EDIT END diff --git a/Content.Shared/Standing/StandingStateSystem.cs b/Content.Shared/Standing/StandingStateSystem.cs index 517831b8a1..212e1d06d1 100644 --- a/Content.Shared/Standing/StandingStateSystem.cs +++ b/Content.Shared/Standing/StandingStateSystem.cs @@ -1,7 +1,9 @@ +using Content.Shared.Buckle; +using Content.Shared.Buckle.Components; using Content.Shared.Hands.Components; +using Content.Shared.Movement.Systems; using Content.Shared.Physics; using Content.Shared.Rotation; -using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; using Robust.Shared.Physics; using Robust.Shared.Physics.Systems; @@ -13,6 +15,8 @@ public sealed class StandingStateSystem : EntitySystem [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedPhysicsSystem _physics = default!; + [Dependency] private readonly MovementSpeedModifierSystem _movement = default!; // WD EDIT + [Dependency] private readonly SharedBuckleSystem _buckle = default!; // WD EDIT // If StandingCollisionLayer value is ever changed to more than one layer, the logic needs to be edited. private const int StandingCollisionLayer = (int) CollisionGroup.MidImpassable; @@ -22,7 +26,7 @@ public bool IsDown(EntityUid uid, StandingStateComponent? standingState = null) if (!Resolve(uid, ref standingState, false)) return false; - return !standingState.Standing; + return standingState.CurrentState is StandingState.Lying or StandingState.GettingUp; } public bool Down(EntityUid uid, bool playSound = true, bool dropHeldItems = true, @@ -37,7 +41,7 @@ public bool Down(EntityUid uid, bool playSound = true, bool dropHeldItems = true // Optional component. Resolve(uid, ref appearance, ref hands, false); - if (!standingState.Standing) + if (standingState.CurrentState is StandingState.Lying or StandingState.GettingUp) return true; // This is just to avoid most callers doing this manually saving boilerplate @@ -49,13 +53,16 @@ public bool Down(EntityUid uid, bool playSound = true, bool dropHeldItems = true RaiseLocalEvent(uid, new DropHandItemsEvent(), false); } + if (TryComp(uid, out BuckleComponent? buckle) && buckle.Buckled && !_buckle.TryUnbuckle(uid, uid, buckleComp: buckle)) // WD EDIT + return false; + var msg = new DownAttemptEvent(); RaiseLocalEvent(uid, msg, false); if (msg.Cancelled) return false; - standingState.Standing = false; + standingState.CurrentState = StandingState.Lying; Dirty(standingState); RaiseLocalEvent(uid, new DownedEvent(), false); @@ -82,9 +89,10 @@ public bool Down(EntityUid uid, bool playSound = true, bool dropHeldItems = true if (playSound) { - _audio.PlayPredicted(standingState.DownSound, uid, uid); + _audio.PlayPredicted(standingState.DownSound, uid, null); } + _movement.RefreshMovementSpeedModifiers(uid); // WD EDIT return true; } @@ -100,9 +108,12 @@ public bool Stand(EntityUid uid, // Optional component. Resolve(uid, ref appearance, false); - if (standingState.Standing) + if (standingState.CurrentState is StandingState.Standing) return true; + if (TryComp(uid, out BuckleComponent? buckle) && buckle.Buckled && !_buckle.TryUnbuckle(uid, uid, buckleComp: buckle)) // WD EDIT + return false; + if (!force) { var msg = new StandAttemptEvent(); @@ -112,7 +123,7 @@ public bool Stand(EntityUid uid, return false; } - standingState.Standing = true; + standingState.CurrentState = StandingState.Standing; Dirty(uid, standingState); RaiseLocalEvent(uid, new StoodEvent(), false); @@ -127,6 +138,7 @@ public bool Stand(EntityUid uid, } } standingState.ChangedFixtures.Clear(); + _movement.RefreshMovementSpeedModifiers(uid); // WD EDIT return true; } diff --git a/Content.Shared/Station/SharedStationSpawningSystem.cs b/Content.Shared/Station/SharedStationSpawningSystem.cs index ea0898824b..9e93318103 100644 --- a/Content.Shared/Station/SharedStationSpawningSystem.cs +++ b/Content.Shared/Station/SharedStationSpawningSystem.cs @@ -52,7 +52,6 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG } if (TryComp(entity, out HandsComponent? handsComponent)) - return; { var inhand = startingGear.Inhand; var coords = EntityManager.GetComponent(entity).Coordinates; @@ -61,8 +60,10 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG var inhandEntity = EntityManager.SpawnEntity(prototype, coords); if (_handsSystem.TryGetEmptyHand(entity, out var emptyHand, handsComponent)) + { _handsSystem.TryPickup(entity, inhandEntity, emptyHand, checkActionBlocker: false, handsComp: handsComponent); + } } } @@ -78,7 +79,9 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG continue; foreach (var ent in entProtos) + { ents.Add(Spawn(ent, coords)); + } if (inventoryComp == null || !InventorySystem.TryGetSlotEntity(entity, slot, out var slotEnt, @@ -87,7 +90,9 @@ public void EquipStartingGear(EntityUid entity, StartingGearPrototype? startingG continue; foreach (var ent in ents) + { _storage.Insert(slotEnt.Value, ent, out _, storageComp: storage, playSound: false); + } } } } diff --git a/Content.Shared/Stunnable/SharedStunSystem.cs b/Content.Shared/Stunnable/SharedStunSystem.cs index c447f8c8bc..3f6ef8999d 100644 --- a/Content.Shared/Stunnable/SharedStunSystem.cs +++ b/Content.Shared/Stunnable/SharedStunSystem.cs @@ -1,3 +1,4 @@ +using Content.Shared._White.Standing; using Content.Shared.ActionBlocker; using Content.Shared.Administration.Logs; using Content.Shared.Audio; @@ -19,6 +20,7 @@ using Content.Shared.Throwing; using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; +using Robust.Shared.Containers; using Robust.Shared.GameStates; using Robust.Shared.Player; @@ -32,6 +34,8 @@ public abstract class SharedStunSystem : EntitySystem [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly StandingStateSystem _standingState = default!; [Dependency] private readonly StatusEffectsSystem _statusEffect = default!; + [Dependency] private readonly SharedLayingDownSystem _layingDown = default!; // WD EDIT + [Dependency] private readonly SharedContainerSystem _container = default!; // WD EDIT /// /// Friction modifier for knocked down players. @@ -109,12 +113,25 @@ private void UpdateCanMove(EntityUid uid, StunnedComponent component, EntityEven private void OnKnockInit(EntityUid uid, KnockedDownComponent component, ComponentInit args) { - _standingState.Down(uid); + RaiseNetworkEvent(new CheckAutoGetUpEvent(GetNetEntity(uid))); // WD EDIT + _layingDown.TryLieDown(uid, null, null, DropHeldItemsBehavior.DropIfStanding); // WD EDIT } private void OnKnockShutdown(EntityUid uid, KnockedDownComponent component, ComponentShutdown args) { - _standingState.Stand(uid); + // WD EDIT START + if (!TryComp(uid, out StandingStateComponent? standing)) + return; + + if (TryComp(uid, out LayingDownComponent? layingDown)) + { + if (layingDown.AutoGetUp && !_container.IsEntityInContainer(uid)) + _layingDown.TryStandUp(uid, layingDown); + return; + } + + _standingState.Stand(uid, standing); + // WD EDIT END } private void OnStandAttempt(EntityUid uid, KnockedDownComponent component, StandAttemptEvent args) diff --git a/Content.Shared/_White/CVars.cs b/Content.Shared/_White/CVars.cs index 065956993e..3748706b39 100644 --- a/Content.Shared/_White/CVars.cs +++ b/Content.Shared/_White/CVars.cs @@ -7,6 +7,9 @@ public sealed class WhiteCVars { #region Keybind + public static readonly CVarDef AutoGetUp = + CVarDef.Create("white.auto_get_up", true, CVar.CLIENT | CVar.ARCHIVE | CVar.REPLICATED); + public static readonly CVarDef HoldLookUp = CVarDef.Create("white.hold_look_up", false, CVar.CLIENT | CVar.ARCHIVE); diff --git a/Content.Shared/_White/Standing/LayingDownComponent.cs b/Content.Shared/_White/Standing/LayingDownComponent.cs new file mode 100644 index 0000000000..6dce32ac24 --- /dev/null +++ b/Content.Shared/_White/Standing/LayingDownComponent.cs @@ -0,0 +1,25 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; + +namespace Content.Shared._White.Standing; + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class LayingDownComponent : Component +{ + [DataField, AutoNetworkedField, ViewVariables(VVAccess.ReadWrite)] + public float StandingUpTime { get; set; } = 1f; + + [DataField, AutoNetworkedField, ViewVariables(VVAccess.ReadWrite)] + public float SpeedModify { get; set; } = 0.4f; + + [DataField, AutoNetworkedField, ViewVariables(VVAccess.ReadWrite)] + public bool AutoGetUp; +} +[Serializable, NetSerializable] +public sealed class ChangeLayingDownEvent : CancellableEntityEventArgs; + +[Serializable, NetSerializable] +public sealed class CheckAutoGetUpEvent(NetEntity user) : CancellableEntityEventArgs +{ + public NetEntity User = user; +} diff --git a/Content.Shared/_White/Standing/SharedLayingDownSystem.cs b/Content.Shared/_White/Standing/SharedLayingDownSystem.cs new file mode 100644 index 0000000000..2406d19a37 --- /dev/null +++ b/Content.Shared/_White/Standing/SharedLayingDownSystem.cs @@ -0,0 +1,162 @@ +using Content.Shared.DoAfter; +using Content.Shared.Gravity; +using Content.Shared.Input; +using Content.Shared.Mobs.Systems; +using Content.Shared.Movement.Systems; +using Content.Shared.Standing; +using Content.Shared.Stunnable; +using Robust.Shared.Input.Binding; +using Robust.Shared.Player; +using Robust.Shared.Serialization; + +namespace Content.Shared._White.Standing; + +public abstract class SharedLayingDownSystem : EntitySystem +{ + [Dependency] private readonly MobStateSystem _mobState = default!; + [Dependency] private readonly StandingStateSystem _standing = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; + [Dependency] private readonly SharedGravitySystem _gravity = default!; + + public override void Initialize() + { + CommandBinds.Builder + .Bind(ContentKeyFunctions.ToggleStanding, InputCmdHandler.FromDelegate(ToggleStanding)) + .Register(); + + SubscribeNetworkEvent(OnChangeState); + + SubscribeLocalEvent(OnStandingUpDoAfter); + SubscribeLocalEvent(OnRefreshMovementSpeed); + SubscribeLocalEvent(OnParentChanged); + } + + public override void Shutdown() + { + base.Shutdown(); + + CommandBinds.Unregister(); + } + + private void ToggleStanding(ICommonSession? session) + { + if (session?.AttachedEntity == null || + !HasComp(session.AttachedEntity) || + _gravity.IsWeightless(session.AttachedEntity.Value)) + { + return; + } + + RaiseNetworkEvent(new ChangeLayingDownEvent()); + } + + private void OnChangeState(ChangeLayingDownEvent ev, EntitySessionEventArgs args) + { + if (!args.SenderSession.AttachedEntity.HasValue) + return; + + var uid = args.SenderSession.AttachedEntity.Value; + + // TODO: Wizard + //if (HasComp(uid)) + // return; + + if (!TryComp(uid, out StandingStateComponent? standing) || + !TryComp(uid, out LayingDownComponent? layingDown)) + { + return; + } + + RaiseNetworkEvent(new CheckAutoGetUpEvent(GetNetEntity(uid))); + + if (HasComp(uid) || !_mobState.IsAlive(uid)) + return; + + if (_standing.IsDown(uid, standing)) + TryStandUp(uid, layingDown, standing); + else + TryLieDown(uid, layingDown, standing); + } + + private void OnStandingUpDoAfter(EntityUid uid, StandingStateComponent component, StandingUpDoAfterEvent args) + { + if (args.Handled || args.Cancelled || HasComp(uid) || + _mobState.IsIncapacitated(uid) || !_standing.Stand(uid)) + { + component.CurrentState = StandingState.Lying; + } + + component.CurrentState = StandingState.Standing; + } + + private void OnRefreshMovementSpeed(EntityUid uid, LayingDownComponent component, RefreshMovementSpeedModifiersEvent args) + { + if (_standing.IsDown(uid)) + args.ModifySpeed(component.SpeedModify, component.SpeedModify); + else + args.ModifySpeed(1f, 1f); + } + + private void OnParentChanged(EntityUid uid, LayingDownComponent component, EntParentChangedMessage args) + { + // If the entity is not on a grid, try to make it stand up to avoid issues + if (!TryComp(uid, out var standingState) + || standingState.CurrentState is StandingState.Standing + || Transform(uid).GridUid != null) + { + return; + } + + _standing.Stand(uid, standingState); + } + + public bool TryStandUp(EntityUid uid, LayingDownComponent? layingDown = null, StandingStateComponent? standingState = null) + { + if (!Resolve(uid, ref standingState, false) || + !Resolve(uid, ref layingDown, false) || + standingState.CurrentState is not StandingState.Lying || + !_mobState.IsAlive(uid) || + TerminatingOrDeleted(uid)) + { + return false; + } + + var args = new DoAfterArgs(EntityManager, uid, layingDown.StandingUpTime, new StandingUpDoAfterEvent(), uid) + { + BreakOnHandChange = false, + RequireCanInteract = false + }; + + if (!_doAfter.TryStartDoAfter(args)) + return false; + + standingState.CurrentState = StandingState.GettingUp; + return true; + } + + public bool TryLieDown(EntityUid uid, LayingDownComponent? layingDown = null, StandingStateComponent? standingState = null, DropHeldItemsBehavior behavior = DropHeldItemsBehavior.NoDrop) + { + if (!Resolve(uid, ref standingState, false) || + !Resolve(uid, ref layingDown, false) || + standingState.CurrentState is not StandingState.Standing) + { + if (behavior == DropHeldItemsBehavior.AlwaysDrop) + RaiseLocalEvent(uid, new DropHandItemsEvent()); + + return false; + } + + _standing.Down(uid, true, behavior != DropHeldItemsBehavior.NoDrop, standingState); + return true; + } +} + +[Serializable, NetSerializable] +public sealed partial class StandingUpDoAfterEvent : SimpleDoAfterEvent; + +public enum DropHeldItemsBehavior : byte +{ + NoDrop, + DropIfStanding, + AlwaysDrop +} diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml index 89759da52f..472ea981d9 100644 --- a/Resources/Changelog/Changelog.yml +++ b/Resources/Changelog/Changelog.yml @@ -5413,3 +5413,23 @@ Entries: message: Добавлен предмет аплинка - ЭМИ фонарик. id: 6272 time: '2024-08-23T02:53:41.0000000+00:00' +- author: Spatison + changes: + - type: Add + message: Added lying down system / Добавлена система лежания + id: 6273 + time: '2024-08-23T13:30:07.0000000+00:00' +- author: Spatison + changes: + - type: Tweak + message: Press to walk / Нажать чтобы идти + id: 6274 + time: '2024-08-23T13:30:51.0000000+00:00' +- author: Remuchi + changes: + - type: Fix + message: >- + Thieves and Ninjas now properly spawn with their gear / Ниндзя и воры + теперь корректно спавнятся со своим снаряжением. + id: 6275 + time: '2024-08-24T04:45:51.0000000+00:00' diff --git a/Resources/Locale/en-US/_white/escape-menu/options-menu.ftl b/Resources/Locale/en-US/_white/escape-menu/options-menu.ftl index b94df35ba6..c2ebf31844 100644 --- a/Resources/Locale/en-US/_white/escape-menu/options-menu.ftl +++ b/Resources/Locale/en-US/_white/escape-menu/options-menu.ftl @@ -1,2 +1,3 @@ ui-options-function-look-up = Look up/Take aim +ui-options-function-auto-get-up = Automatically get up after falling ui-options-function-hold-look-up = Hold down the key to aim \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_white/escape-menu/options-menu.ftl b/Resources/Locale/ru-RU/_white/escape-menu/options-menu.ftl index b8667e14c0..eebde7a272 100644 --- a/Resources/Locale/ru-RU/_white/escape-menu/options-menu.ftl +++ b/Resources/Locale/ru-RU/_white/escape-menu/options-menu.ftl @@ -1,2 +1,3 @@ ui-options-function-look-up = Присмотреться/Прицелиться +ui-options-function-auto-get-up = Автоматически вставать при падении ui-options-function-hold-look-up = Удерживать клавишу для прицеливания \ No newline at end of file