diff --git a/EXILED/Exiled.API/Features/Player.cs b/EXILED/Exiled.API/Features/Player.cs index 11bacfe84e..c8bd4f4083 100644 --- a/EXILED/Exiled.API/Features/Player.cs +++ b/EXILED/Exiled.API/Features/Player.cs @@ -133,6 +133,7 @@ public Player(GameObject gameObject) DictionaryPool.Pool.Return(SessionVariables); DictionaryPool.Pool.Return(FriendlyFireMultiplier); DictionaryPool>.Pool.Return(CustomRoleFriendlyFireMultiplier); + ListPool>.Pool.Return(FakeRoleGenerator); } /// @@ -413,6 +414,11 @@ public float InfoViewRange /// public Dictionary SessionVariables { get; } = DictionaryPool.Pool.Get(); + /// + /// Gets a dictionary that contains from this players POV, a dictionary containing other players and their faked roles with custom data. + /// + public Dictionary FakeRoles { get; } = new(); + /// /// Gets a value indicating whether the player has Do Not Track (DNT) enabled. If this value is , data about the player unrelated to server security shouldn't be stored. /// @@ -613,6 +619,12 @@ internal set } } + /// + /// Gets a of generating a to fake this players role whenever this player changes role. + /// + /// See for usage. + public List> FakeRoleGenerator { get; } = ListPool>.Pool.Get(); + /// /// Gets the role that player had before changing role. /// @@ -1911,6 +1923,50 @@ public void TrySetCustomRoleFriendlyFire(string roleTypeId, Dictionary Whether the item was able to be added. public bool TryRemoveCustomeRoleFriendlyFire(string role) => CustomRoleFriendlyFireMultiplier.Remove(role); + /// + /// Adds a from a to a that is used every time this players role changes. + /// + /// The function that determines if this players role will be faked (to a viewer) after their role changes. + /// The first Func in that returns a RoleData that is not will be used for faking appearance. + /// An example use case would be to make a scientist appear as a Class-D to all other Class-D, that Func would look like: + /// + /// player => player.Role.Team is Team.ClassD ? new RoleData(RoleTypeId.ClassD) : RoleData.None + /// + /// This method can be further optimized by only using static RoleData instances in your Funcs. + /// + /// + public void SetAppearance(Func generator) + { + FakeRoleGenerator.Add(generator); + } + + /// + /// Fakes this players role to other viewers. + /// + /// The players to affect. + /// The fake role. + /// How to handle edge cases. + /// The Unit ID of the player, if is an NTF role. + public void SetAppearance(IEnumerable viewers, RoleTypeId fakeRole, RoleData.Authority authority = RoleData.Authority.None, byte unitId = 0) + { + foreach (Player player in viewers) + { + player.SetAppearance(this, fakeRole, authority, unitId); + } + } + + /// + /// Fakes another players role to this player. + /// + /// The target. + /// The fake role. + /// How to handle edge cases. + /// The Unit ID of the player, if is an NTF role. + public void SetAppearance(Player player, RoleTypeId fakeRole, RoleData.Authority authority = RoleData.Authority.None, byte unitId = 0) + { + FakeRoles[player] = new RoleData(fakeRole, authority, unitId); + } + /// /// Forces the player's client to play the weapon reload animation, bypassing server-side checks. /// diff --git a/EXILED/Exiled.API/Structs/RoleData.cs b/EXILED/Exiled.API/Structs/RoleData.cs new file mode 100644 index 0000000000..66eb1e8f52 --- /dev/null +++ b/EXILED/Exiled.API/Structs/RoleData.cs @@ -0,0 +1,124 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Structs +{ + using System; + + using Mirror; + using PlayerRoles; + + /// + /// A struct representing all data regarding a fake role. + /// + public struct RoleData : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + /// The fake role. + /// The authority of the role data. + /// The fake UnitID, if is an NTF role. + /// An action used to write custom data if this role data is used to send a fake role. For 99% of uses, just leave this as null. + public RoleData(RoleTypeId role, Authority authority = Authority.None, byte unitId = 0, Action customData = null) + { + Role = role; + DataAuthority = authority; + UnitId = unitId; + CustomData = customData; + } + + /// + /// Represents flags for how Exiled should handle edge cases. + /// + [Flags] + public enum Authority + { + /// + /// Indicates Exiled should only fake the role of the target of this in ideal conditions. + /// + None = 0, + + /// + /// Indicates that Exiled should attempt to override other plugins fake role attempts if they exist. + /// + /// This is not guaranteed to always work. + Override = 1, + + /// + /// Indicates that the fake role should always be sent without checking if the player is dead, etc... + /// + Always = 2, + + /// + /// Indicates that Exiled should not reset the fake role if the target of this dies. + /// + Persist = 4, + + /// + /// Indicates that this can make a player view themselves as a different role. + /// + AffectSelf = 8, + + /// + /// Indicates that this can affect dummies. + /// + AffectNPCs = 16, + } + + /// + /// Gets the static representing no data. + /// + public static RoleData None { get; } = new(RoleTypeId.None); + + /// + /// Gets or sets the fake role. + /// + public RoleTypeId Role { get; set; } + + /// + /// Gets or sets the UnitID of the fake role, if is an NTF role. + /// + public byte UnitId { get; set; } + + /// + /// Gets or sets the authority of this instance. see for details. + /// + public Authority DataAuthority { get; set; } = Authority.None; + + /// + /// Gets or sets custom data written to network writers when fake data is generated. + /// + /// Leave this value as null unless you are writing custom role-specific data. + public Action CustomData { get; set; } + + /// + /// Checks if 2 are equal. + /// + /// A . + /// The other . + /// Whether the parameters are equal. + public static bool operator ==(RoleData left, RoleData right) => left.Equals(right); + + /// + /// Checks if 2 are not equal. + /// + /// A . + /// The other . + /// Whether the parameters are not equal. + public static bool operator !=(RoleData left, RoleData right) => !left.Equals(right); + + /// + public bool Equals(RoleData other) => Role == other.Role && DataAuthority == other.DataAuthority && UnitId == other.UnitId; + + /// + public override bool Equals(object obj) => obj is RoleData other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine((int)Role, UnitId, (int)DataAuthority, CustomData); + } +} \ No newline at end of file diff --git a/EXILED/Exiled.Events/Events.cs b/EXILED/Exiled.Events/Events.cs index 8067335576..48c82eec7d 100644 --- a/EXILED/Exiled.Events/Events.cs +++ b/EXILED/Exiled.Events/Events.cs @@ -18,6 +18,7 @@ namespace Exiled.Events using HarmonyLib; using InventorySystem.Items.Pickups; using InventorySystem.Items.Usables; + using PlayerRoles.FirstPersonControl.NetworkMessages; using PlayerRoles.Ragdolls; using PlayerRoles.RoleAssign; @@ -68,6 +69,8 @@ public override void OnEnabled() Handlers.Server.RestartingRound += Handlers.Internal.Round.OnRestartingRound; Handlers.Server.RoundStarted += Handlers.Internal.Round.OnRoundStarted; Handlers.Player.ChangingRole += Handlers.Internal.Round.OnChangingRole; + Handlers.Player.Spawned += Handlers.Internal.Round.OnSpawned; + Handlers.Player.Dying.Subscribe(Handlers.Internal.Round.OnDying, -100); Handlers.Player.SpawningRagdoll += Handlers.Internal.Round.OnSpawningRagdoll; Handlers.Scp049.ActivatingSense += Handlers.Internal.Round.OnActivatingSense; Handlers.Player.Verified += Handlers.Internal.Round.OnVerified; @@ -93,6 +96,8 @@ public override void OnEnabled() LabApi.Events.Handlers.PlayerEvents.ReloadingWeapon += Handlers.Player.OnReloadingWeapon; LabApi.Events.Handlers.PlayerEvents.UnloadingWeapon += Handlers.Player.OnUnloadingWeapon; + FpcServerPositionDistributor.RoleSyncEvent += Handlers.Internal.Round.OnRoleSyncEvent; + LabApi.Events.Handlers.Scp127Events.Talking += Handlers.Scp127.OnTalking; LabApi.Events.Handlers.Scp127Events.Talked += Handlers.Scp127.OnTalked; LabApi.Events.Handlers.Scp127Events.GainingExperience += Handlers.Scp127.OnGainingExperience; @@ -118,6 +123,8 @@ public override void OnDisabled() Handlers.Server.RestartingRound -= Handlers.Internal.Round.OnRestartingRound; Handlers.Server.RoundStarted -= Handlers.Internal.Round.OnRoundStarted; Handlers.Player.ChangingRole -= Handlers.Internal.Round.OnChangingRole; + Handlers.Player.Spawned -= Handlers.Internal.Round.OnSpawned; + Handlers.Player.Dying -= Handlers.Internal.Round.OnDying; Handlers.Player.SpawningRagdoll -= Handlers.Internal.Round.OnSpawningRagdoll; Handlers.Scp049.ActivatingSense -= Handlers.Internal.Round.OnActivatingSense; Handlers.Player.Verified -= Handlers.Internal.Round.OnVerified; @@ -138,6 +145,8 @@ public override void OnDisabled() LabApi.Events.Handlers.PlayerEvents.ReloadingWeapon -= Handlers.Player.OnReloadingWeapon; LabApi.Events.Handlers.PlayerEvents.UnloadingWeapon -= Handlers.Player.OnUnloadingWeapon; + FpcServerPositionDistributor.RoleSyncEvent -= Handlers.Internal.Round.OnRoleSyncEvent; + LabApi.Events.Handlers.Scp127Events.Talking -= Handlers.Scp127.OnTalking; LabApi.Events.Handlers.Scp127Events.Talked -= Handlers.Scp127.OnTalked; LabApi.Events.Handlers.Scp127Events.GainingExperience -= Handlers.Scp127.OnGainingExperience; diff --git a/EXILED/Exiled.Events/Handlers/Internal/Round.cs b/EXILED/Exiled.Events/Handlers/Internal/Round.cs index 4ec235db18..140e66d4c3 100644 --- a/EXILED/Exiled.Events/Handlers/Internal/Round.cs +++ b/EXILED/Exiled.Events/Handlers/Internal/Round.cs @@ -7,6 +7,7 @@ namespace Exiled.Events.Handlers.Internal { + using System; using System.Collections.Generic; using System.Linq; @@ -29,9 +30,13 @@ namespace Exiled.Events.Handlers.Internal using InventorySystem.Items.Usables; using InventorySystem.Items.Usables.Scp244.Hypothermia; using InventorySystem.Items.Usables.Scp330; + using Mirror; using PlayerRoles; using PlayerRoles.FirstPersonControl; using PlayerRoles.RoleAssign; + using PlayerRoles.SpawnData; + using RelativePositioning; + using Respawning.NamingRules; using UnityEngine; using Utils.Networking; using Utils.NonAllocLINQ; @@ -41,6 +46,12 @@ namespace Exiled.Events.Handlers.Internal /// internal static class Round { + /// + /// Gets or sets a value indicating whether is going to be invoked from . + /// + /// This is required to check if we can skip writing all the data for a fake role without looking inside the stack trace (very expensive compared to a patch). + internal static bool SendingNewRoleInfo { get; set; } + /// public static void OnServerOnUsingCompleted(ReferenceHub hub, UsableItem usable) => Handlers.Player.OnUsedItem(new (hub, usable, false)); @@ -89,6 +100,39 @@ public static void OnChangingRole(ChangingRoleEventArgs ev) ev.Player.Inventory.ServerDropEverything(); } + /// + public static void OnSpawned(SpawnedEventArgs ev) + { + foreach (Player viewer in Player.Enumerable.Where(p => !p.IsNPC && !p.IsHost)) + { + foreach (Func generator in ev.Player.FakeRoleGenerator) + { + RoleData data = generator(viewer); + + if (data.Role == RoleTypeId.None) + continue; + + if (viewer != ev.Player) + { + viewer.FakeRoles[ev.Player] = data; + } + } + } + } + + /// + public static void OnDying(DyingEventArgs ev) + { + if (!ev.IsAllowed) + return; + + foreach (Player viewer in Player.Enumerable.Where(p => !p.IsNPC && !p.IsHost)) + { + if (viewer.FakeRoles.TryGetValue(ev.Player, out RoleData data) && (data.DataAuthority & RoleData.Authority.Persist) == RoleData.Authority.None) + viewer.FakeRoles.Remove(ev.Player); + } + } + /// public static void OnSpawningRagdoll(SpawningRagdollEventArgs ev) { @@ -125,9 +169,122 @@ public static void OnVerified(VerifiedEventArgs ev) foreach (Player player in ReferenceHub.AllHubs.Select(Player.Get)) { player.SetFakeScale(player.Scale, new List() { ev.Player }); + + foreach (Func generator in player.FakeRoleGenerator) + { + RoleData data = generator(ev.Player); + + if (data.Role == RoleTypeId.None) + continue; + + if (player != ev.Player) + { + ev.Player.FakeRoles[player] = data; + } + } } } + /// + /// Makes fake role API work. + /// + /// The of the target. + /// The of the viewer. + /// The actual . + /// The pooled . + /// A role, fake if needed. + public static RoleTypeId OnRoleSyncEvent(ReferenceHub targetHub, ReferenceHub viewerHub, RoleTypeId actualRole, NetworkWriter writer) + { + Player target = Player.Get(targetHub); + Player viewer = Player.Get(viewerHub); + + if (viewer.IsHost || !viewer.FakeRoles.TryGetValue(target, out RoleData data)) + return actualRole; + + if (target == viewer && (data.DataAuthority & RoleData.Authority.AffectSelf) == RoleData.Authority.None) + return actualRole; + + // if another plugin has written data, we can't reliably modify and expect non-breaking behavior. + // if we send faulty data we can accidentally soft-dc the entire server which is much worse than a plugin not working. + if (writer.Position != 0 && (data.DataAuthority & RoleData.Authority.Override) == RoleData.Authority.None) + return actualRole; + + if ((data.DataAuthority & RoleData.Authority.Always) == RoleData.Authority.None && actualRole.IsDead()) + return actualRole; + + if ((data.DataAuthority & RoleData.Authority.AffectNPCs) == RoleData.Authority.None && target.IsNPC) + return actualRole; + + // this check has to be last because otherwise you can get instances where a fake role shouldn't persist due to not having a required Authority, + // yet it would still persist because this would return the fake role if it was not here. + if (!SendingNewRoleInfo && targetHub.roleManager.PreviouslySentRole.TryGetValue(viewerHub.netId, out RoleTypeId previousRole) && previousRole == data.Role) + return previousRole; + + Log.Info($"RoleSyncEvent called with {viewer.Nickname} as the viewer and {target.Nickname} as the target"); + + writer.Position = 0; + + if (data.CustomData != null) + { + data.CustomData(writer); + } + else + { + PlayerRoleBase roleBase = data.Role.GetRoleBase(); + + if (roleBase is not ISpawnDataReader) + return data.Role; + + switch (roleBase) + { + case PlayerRoles.HumanRole { UsesUnitNames: true } when data.UnitId != 0: + writer.WriteByte(data.UnitId); + break; + + // W stylecop :heart: +#pragma warning disable SA1013 + case PlayerRoles.HumanRole { UsesUnitNames: true }: +#pragma warning restore SA1013 + { + if (!NamingRulesManager.GeneratedNames.TryGetValue(Team.FoundationForces, out List list)) + return actualRole; + + writer.WriteByte((byte)list.Count); + break; + } + + case PlayerRoles.PlayableScps.Scp1507.Scp1507Role flamingo: + writer.WriteByte((byte)flamingo.ServerSpawnReason); + break; + } + + if (data.Role == RoleTypeId.Scp0492) + { + writer.WriteUShort((ushort)Mathf.Clamp(Mathf.CeilToInt(target.MaxHealth), 0, ushort.MaxValue)); + writer.WriteBool(false); + } + + if (target.Role is FpcRole role) + { + Log.Info("Writing normal FPC stuff"); + + writer.WriteRelativePosition(role.ClientRelativePosition); + writer.WriteUShort(role.FirstPersonController.FpcModule.MouseLook._prevSyncH); + } + else + { + Log.Info("Writing FPC stuff using inefficient method"); + + WaypointBase.GetRelativeRotation(target.Position, Quaternion.Euler(Vector3.up * target.Rotation.eulerAngles.y), out _, out Quaternion relativeRotation); + + writer.WriteRelativePosition(new RelativePosition(target.Position)); + writer.WriteUShort((ushort)Mathf.RoundToInt(Mathf.InverseLerp(0F, 360F, relativeRotation.eulerAngles.y) * ushort.MaxValue)); + } + } + + return data.Role; + } + /// public static void OnWarheadDetonated() { diff --git a/EXILED/Exiled.Events/Patches/Generic/RoleSyncCallerCheck.cs b/EXILED/Exiled.Events/Patches/Generic/RoleSyncCallerCheck.cs new file mode 100644 index 0000000000..528b69d672 --- /dev/null +++ b/EXILED/Exiled.Events/Patches/Generic/RoleSyncCallerCheck.cs @@ -0,0 +1,46 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.Patches.Generic +{ + using System.Collections.Generic; + using System.Reflection.Emit; + + using Exiled.API.Features.Pools; + using Exiled.Events.Handlers.Internal; + using HarmonyLib; + using PlayerRoles; + + using static HarmonyLib.AccessTools; + + /// + /// Patches to check if we can skip writing all the data for a fake role inside without looking inside the stack trace. + /// + [HarmonyPatch(typeof(PlayerRoleManager), nameof(PlayerRoleManager.SendNewRoleInfo))] + internal class RoleSyncCallerCheck + { + private static IEnumerable Transpiler(IEnumerable instructions) + { + List newInstructions = ListPool.Pool.Get(instructions); + + yield return new CodeInstruction(OpCodes.Ldc_I4_1); + yield return new CodeInstruction(OpCodes.Call, PropertySetter(typeof(Round), nameof(Round.SendingNewRoleInfo))); + + int z; + for (z = 0; z < newInstructions.Count - 1; z++) + yield return newInstructions[z]; + CodeInstruction ret = newInstructions[z]; + + yield return new CodeInstruction(OpCodes.Ldc_I4_0).MoveLabelsFrom(ret); + yield return new CodeInstruction(OpCodes.Call, PropertySetter(typeof(Round), nameof(Round.SendingNewRoleInfo))); + + yield return ret; + + ListPool.Pool.Return(newInstructions); + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.Events/Patches/Generic/RoleSyncExtraFunctionality.cs b/EXILED/Exiled.Events/Patches/Generic/RoleSyncExtraFunctionality.cs new file mode 100644 index 0000000000..04a95141af --- /dev/null +++ b/EXILED/Exiled.Events/Patches/Generic/RoleSyncExtraFunctionality.cs @@ -0,0 +1,80 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.Patches.Generic +{ + using System.Collections.Generic; + using System.Reflection.Emit; + + using Exiled.API.Features.Pools; + using HarmonyLib; + using Mirror; + using PlayerRoles; + using PlayerRoles.FirstPersonControl; + using PlayerRoles.FirstPersonControl.NetworkMessages; + + using static HarmonyLib.AccessTools; + + /// + /// Patches to make the Exiled Fake Role API works for niche cases. + /// + [HarmonyPatch(typeof(FpcServerPositionDistributor), nameof(FpcServerPositionDistributor.WriteAll))] + internal class RoleSyncExtraFunctionality + { + private static IEnumerable Transpiler(IEnumerable instructions, ILGenerator generator) + { + List newInstructions = ListPool.Pool.Get(instructions); + + Label eventLabel = generator.DefineLabel(); + + int offset = 3; + int index = newInstructions.FindIndex(x => x.Calls(PropertyGetter(typeof(NetworkBehaviour), nameof(NetworkBehaviour.netId)))) + offset; + + Label oldTarget = (Label)newInstructions[index].operand; + newInstructions[index].operand = eventLabel; + + offset = 3; + index = newInstructions.FindIndex(x => x.opcode == OpCodes.Isinst && x.OperandIs(typeof(IFpcRole))) + offset; + + newInstructions[index].operand = eventLabel; + + offset = 0; + index = newInstructions.FindLastIndex(x => x.labels.Contains(oldTarget)) + offset; + + newInstructions.InsertRange(index, new[] + { + new(OpCodes.Br_S, oldTarget), + + new CodeInstruction(OpCodes.Ldloc_S, 5).WithLabels(eventLabel), + new CodeInstruction(OpCodes.Ldarg_0), + + new(OpCodes.Call, Method(typeof(RoleSyncExtraFunctionality), nameof(HandleEvent))), + }); + + for (int z = 0; z < newInstructions.Count; z++) + yield return newInstructions[z]; + + ListPool.Pool.Return(newInstructions); + } + + private static void HandleEvent(ReferenceHub target, ReferenceHub viewer) + { + NetworkWriterPooled writer = NetworkWriterPool.Get(); + + RoleTypeId role = target.GetRoleId(); + RoleTypeId fakeRole = Handlers.Internal.Round.OnRoleSyncEvent(target, viewer, role, writer); + + // largely copy pasted from FpcServerPositionDistributor.SendRole + if (target.roleManager.PreviouslySentRole.TryGetValue(viewer.netId, out role) && role == fakeRole) + return; + + bool fakeOverwatch = FpcServerPositionDistributor.IsDistributionActive(fakeRole); + viewer.connectionToClient.Send(new RoleSyncInfo(target, fakeOverwatch ? RoleTypeId.Overwatch : fakeRole, viewer, writer)); + target.roleManager.PreviouslySentRole[viewer.netId] = fakeRole; + } + } +} \ No newline at end of file