diff --git a/MiraAPI.Example/ExampleEventHandlers.cs b/MiraAPI.Example/ExampleEventHandlers.cs index e2368f7..2e8fdf2 100644 --- a/MiraAPI.Example/ExampleEventHandlers.cs +++ b/MiraAPI.Example/ExampleEventHandlers.cs @@ -1,9 +1,14 @@ -using MiraAPI.Events; +using System.Linq; +using MiraAPI.Events; using MiraAPI.Events.Mira; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Events.Vanilla.Map; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; using MiraAPI.Events.Vanilla.Player; using MiraAPI.Example.Buttons.Freezer; +using MiraAPI.Example.Roles; +using MiraAPI.Utilities; using Reactor.Utilities; namespace MiraAPI.Example; @@ -29,6 +34,41 @@ public static class ExampleEventHandlers }); } + // If you want to add extra votes to a player, do something like this. + [RegisterEvent] + public static void StartMeetingEvent(StartMeetingEvent _) + { + foreach (var plr in PlayerControl.AllPlayerControls) + { + if (plr.Data.Role is MayorRole) + { + plr.GetVoteData().IncreaseRemainingVotes(1); + } + } + } + + // Example of using the Voting API to make a role similar to the Prosecutor from Town of Us. + [RegisterEvent(15)] + public static void HandleVoteEvent(HandleVoteEvent @event) + { + if (@event.VoteData.Owner.Data.Role is not NeutralKillerRole) return; + + @event.VoteData.SetRemainingVotes(0); + + for (var i = 0; i < 5; i++) + { + @event.VoteData.VoteForPlayer(@event.TargetId); + } + + foreach (var plr in PlayerControl.AllPlayerControls.ToArray().Where(player => player != @event.VoteData.Owner)) + { + plr.GetVoteData().Votes.Clear(); + plr.GetVoteData().VotesRemaining = 0; + } + + @event.Cancel(); + } + // Events can be registered using an attribute as well. [RegisterEvent] public static void UpdateSystemEventHandler(UpdateSystemEvent @event) @@ -42,11 +82,10 @@ public static class ExampleEventHandlers { Logger.Warning("Freeze button clicked!"); - if (PlayerControl.LocalPlayer.Data.PlayerName == "stupid") - { - @event.Cancel(); - @event.Button.SetTimer(15f); - } + if (PlayerControl.LocalPlayer.Data.PlayerName != "stupid") return; + + @event.Cancel(); + @event.Button.SetTimer(15f); } // Example event handler diff --git a/MiraAPI.Example/Roles/MayorRole.cs b/MiraAPI.Example/Roles/MayorRole.cs new file mode 100644 index 0000000..42c264f --- /dev/null +++ b/MiraAPI.Example/Roles/MayorRole.cs @@ -0,0 +1,14 @@ +using MiraAPI.Roles; +using UnityEngine; + +namespace MiraAPI.Example.Roles; + +public class MayorRole : CrewmateRole, ICustomRole +{ + public string RoleName => "Mayor"; + public string RoleLongDescription => "You get extra votes!"; + public string RoleDescription => RoleLongDescription; + public Color RoleColor => new Color32(221, 176, 152, 255); + public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public CustomRoleConfiguration Configuration => new(this); +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/CheckForEndVotingEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/CheckForEndVotingEvent.cs new file mode 100644 index 0000000..b0bdae5 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/CheckForEndVotingEvent.cs @@ -0,0 +1,26 @@ +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// Checks if voting is complete. If canceled, the default end voting logic will be skipped. +/// +public class CheckForEndVotingEvent : MiraCancelableEvent +{ + /// + /// Gets a value indicating whether default voting logic determines that voting is complete. + /// + public bool IsVotingComplete { get; } + + /// + /// Gets or sets a value indicating whether the voting should be forced to end, regardless of the default logic. + /// + public bool ForceEndVoting { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// >True if voting is complete, false otherwise. + public CheckForEndVotingEvent(bool isVotingComplete) + { + IsVotingComplete = isVotingComplete; + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs new file mode 100644 index 0000000..93005de --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs @@ -0,0 +1,44 @@ +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// The event that is invoked when Mira handles a vote. This is invoked before Mira's behaviour, so be cautious. +/// If you intend on adding custom vote behaviour, cancel the event and do so. +/// If you are NOT canceling the event, keep in mind that Mira automatically removes a vote and adds the suspect to the VotedPlayers list after this event is invoked. +/// +public class HandleVoteEvent : MiraCancelableEvent +{ + /// + /// Gets the instance of the voter's vote data. + /// + public PlayerVoteData VoteData { get; } + + /// + /// Gets the player who voted. + /// + public PlayerControl Player { get; } + + /// + /// Gets the player id of the target. + /// + public byte TargetId { get; } + + /// + /// Gets the player info of the target. + /// + public NetworkedPlayerInfo TargetPlayerInfo { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The voter's data. + /// The target's playerId. + public HandleVoteEvent(PlayerVoteData playerVoteData, byte targetId) + { + VoteData = playerVoteData; + Player = playerVoteData.Owner; + TargetId = targetId; + TargetPlayerInfo = GameData.Instance.GetPlayerById(targetId); + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/MeetingSelectEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/MeetingSelectEvent.cs new file mode 100644 index 0000000..6ea8eff --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/MeetingSelectEvent.cs @@ -0,0 +1,43 @@ +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// The event that is invoked when the player tries to select another player to vote. +/// +public class MeetingSelectEvent : MiraEvent +{ + /// + /// Gets the instance of the voter's vote data. + /// + public PlayerVoteData VoteData { get; } + + /// + /// Gets the player id of the target. + /// + public int TargetId { get; } + + /// + /// Gets the player info of the target. + /// + public NetworkedPlayerInfo TargetPlayerInfo { get; } + + /// + /// Gets or sets a value indicating whether the player is allowed to select the target. + /// + public bool AllowSelect { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The voter's data. + /// The target's playerId. + /// >Whether the player is allowed to select the target. + public MeetingSelectEvent(PlayerVoteData playerVoteData, int targetId, bool allowSelect) + { + VoteData = playerVoteData; + TargetId = targetId; + TargetPlayerInfo = GameData.Instance.GetPlayerById((byte)targetId); + AllowSelect = allowSelect; + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/PopulateResultsEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/PopulateResultsEvent.cs new file mode 100644 index 0000000..131b6d5 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/PopulateResultsEvent.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// Called in VotingUtils.HandlePopulateResults. +/// Cancelling is NOT advised but if you do plan on cancelling, please ensure you account for displaying ALL player's votes. +/// +public class PopulateResultsEvent : MiraCancelableEvent +{ + /// + /// Gets a list of networked votes. + /// + public List Votes { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The list of votes. + public PopulateResultsEvent(List votes) + { + Votes = votes; + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/ProcessVotesEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/ProcessVotesEvent.cs new file mode 100644 index 0000000..9ecaab0 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/ProcessVotesEvent.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// Ran after calculating votes and before displaying the results. Only ran on the host. +/// +/// +/// Initializes a new instance of the class. +/// +/// >The list of votes that are being processed. +/// The player to be exiled. Will be null if no player is to be exiled. +public class ProcessVotesEvent(List votes, NetworkedPlayerInfo? exiledPlayer = null) : MiraEvent +{ + private readonly List _originalVotes = [.. votes]; + + private NetworkedPlayerInfo? _exiledPlayer = exiledPlayer; + + /// + /// Gets a value indicating whether the exiled player has been modified by the event. + /// + public bool ExiledPlayerModified { get; private set; } + + /// + /// Gets a value indicating whether the votes have been modified by the event. + /// + public bool VotesModified => !Votes.SequenceEqual(_originalVotes); + + /// + /// Gets the list of votes that are being processed. + /// + public List Votes { get; } = votes; + + /// + /// Gets or sets the player to be exiled. Will be null if no player is to be exiled. + /// + public NetworkedPlayerInfo? ExiledPlayer + { + get => _exiledPlayer; + set + { + _exiledPlayer = value; + ExiledPlayerModified = true; + } + } +} diff --git a/MiraAPI/Networking/MiraRpc.cs b/MiraAPI/Networking/MiraRpc.cs index 280fa7e..9ee1088 100644 --- a/MiraAPI/Networking/MiraRpc.cs +++ b/MiraAPI/Networking/MiraRpc.cs @@ -39,4 +39,9 @@ public enum MiraRpc : uint /// Custom Game Over RPC. /// CustomGameOver, + + /// + /// Custom RPC to undo a player's vote. + /// + RemoveVote, } diff --git a/MiraAPI/Patches/Events/ReportBodyEventPatch.cs b/MiraAPI/Patches/Events/ReportBodyEventPatch.cs index 1598436..feba1c9 100644 --- a/MiraAPI/Patches/Events/ReportBodyEventPatch.cs +++ b/MiraAPI/Patches/Events/ReportBodyEventPatch.cs @@ -5,10 +5,12 @@ using MiraAPI.Utilities; namespace MiraAPI.Patches.Events; -[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.CmdReportDeadBody))] +[HarmonyPatch] public static class ReportBodyEventPatch { - public static bool Prefix(PlayerControl __instance, NetworkedPlayerInfo? target) + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.CmdReportDeadBody))] + public static bool PlayerControlCmdReportDeadBodyPrefix(PlayerControl __instance, NetworkedPlayerInfo? target) { var body = target != null ? Helpers.GetBodyById(target.PlayerId) : null; diff --git a/MiraAPI/Patches/Events/StartMeetingEventPatch.cs b/MiraAPI/Patches/Events/StartMeetingEventPatch.cs deleted file mode 100644 index 04eea6f..0000000 --- a/MiraAPI/Patches/Events/StartMeetingEventPatch.cs +++ /dev/null @@ -1,15 +0,0 @@ -using HarmonyLib; -using MiraAPI.Events; -using MiraAPI.Events.Vanilla.Meeting; - -namespace MiraAPI.Patches.Events; - -[HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.Start))] -public static class StartMeetingEventPatch -{ - public static void Postfix(MeetingHud __instance) - { - var @event = new StartMeetingEvent(__instance); - MiraEventManager.InvokeEvent(@event); - } -} diff --git a/MiraAPI/Patches/PlayerControlPatches.cs b/MiraAPI/Patches/PlayerControlPatches.cs index b6fd6b8..feb2921 100644 --- a/MiraAPI/Patches/PlayerControlPatches.cs +++ b/MiraAPI/Patches/PlayerControlPatches.cs @@ -6,6 +6,7 @@ using MiraAPI.Events.Vanilla.Player; using MiraAPI.Hud; using MiraAPI.Modifiers; using MiraAPI.Roles; +using MiraAPI.Voting; using Reactor.Utilities.Extensions; namespace MiraAPI.Patches; @@ -24,12 +25,18 @@ public static class PlayerControlPatches [HarmonyPatch(nameof(PlayerControl.Start))] public static void PlayerControlStartPostfix(PlayerControl __instance) { - if (__instance.gameObject.TryGetComponent(out var comp)) + if (__instance.gameObject.TryGetComponent(out var modifierComp)) { - comp.DestroyImmediate(); + modifierComp.DestroyImmediate(); + } + + if (__instance.gameObject.TryGetComponent(out var voteComp)) + { + voteComp.DestroyImmediate(); } __instance.gameObject.AddComponent(); + __instance.gameObject.AddComponent(); } /// diff --git a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs new file mode 100644 index 0000000..a276946 --- /dev/null +++ b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs @@ -0,0 +1,16 @@ +using HarmonyLib; +using MiraAPI.Utilities; + +namespace MiraAPI.Patches.Voting; + +[HarmonyPatch(typeof(DummyBehaviour), nameof(DummyBehaviour.Update))] +internal static class DummyBehaviourPatch +{ + public static void Postfix(DummyBehaviour __instance) + { + if (MeetingHud.Instance && __instance.myPlayer.GetVoteData()) + { + __instance.voted = __instance.myPlayer.GetVoteData().VotesRemaining == 0; + } + } +} diff --git a/MiraAPI/Patches/Voting/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs new file mode 100644 index 0000000..6070b1e --- /dev/null +++ b/MiraAPI/Patches/Voting/MeetingHudPatches.cs @@ -0,0 +1,237 @@ +using System.Linq; +using HarmonyLib; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.Utilities; +using MiraAPI.Voting; + +namespace MiraAPI.Patches.Voting; + +[HarmonyPatch(typeof(MeetingHud))] +internal static class MeetingHudPatches +{ + [HarmonyPostfix] + [HarmonyPatch(nameof(MeetingHud.Start))] + public static void MeetingHudStartPatch(MeetingHud __instance) + { + foreach (var plr in PlayerControl.AllPlayerControls) + { + var voteData = plr.GetVoteData(); + voteData.Votes.Clear(); + voteData.SetRemainingVotes(1); + + if (plr.Data.IsDead || plr.Data.Disconnected) + { + voteData.SetRemainingVotes(0); + } + } + var @event = new StartMeetingEvent(__instance); + MiraEventManager.InvokeEvent(@event); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.Select))] + public static bool SelectPatch(MeetingHud __instance, int suspectStateIdx) + { + var voteData = PlayerControl.LocalPlayer.GetVoteData(); + + var hasVotes = voteData.VotesRemaining > 0; + var hasVotedFor = voteData.VotedFor((byte)suspectStateIdx); + + var @event = new MeetingSelectEvent(voteData, suspectStateIdx, hasVotes && !hasVotedFor); + MiraEventManager.InvokeEvent(@event); + + return @event.AllowSelect; + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.HandleDisconnect), typeof(PlayerControl), typeof(DisconnectReasons))] + public static bool HandleDisconnect(MeetingHud __instance, PlayerControl pc) + { + if (!AmongUsClient.Instance.AmHost || __instance.playerStates is null || !pc || !GameData.Instance) + { + return false; + } + + var playerVoteArea = __instance.playerStates.First(pv => pv.TargetPlayerId == pc.PlayerId); + playerVoteArea.AmDead = true; + playerVoteArea.Overlay.gameObject.SetActive(true); + + foreach (var player in Helpers.GetAlivePlayers()) + { + var pva = __instance.playerStates.First(pv => pv.TargetPlayerId == player.PlayerId); + var voteData = player.GetVoteData(); + + if (pva.AmDead || !voteData.VotedFor(pc.PlayerId)) + { + continue; + } + + voteData.Votes.RemoveAll(x=>x.Suspect==pc.PlayerId); + voteData.VotesRemaining += 1; + + VotingUtils.RpcRemoveVote(PlayerControl.LocalPlayer, player.PlayerId, pc.PlayerId); + } + + __instance.SetDirtyBit(1U); + __instance.CheckForEndVoting(); + + if (__instance.state == MeetingHud.VoteStates.Results) + { + __instance.SetupProceedButton(); + } + + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.CastVote))] + public static bool CastVotePatch(MeetingHud __instance, byte srcPlayerId, byte suspectPlayerId) + { + var plr = GameData.Instance.GetPlayerById(srcPlayerId); + + if (plr == null) + { + return false; + } + + var voteData = plr.Object.GetVoteData(); + if (voteData == null || voteData.VotesRemaining == 0 || + voteData.VotedFor(suspectPlayerId)) + { + return false; + } + + VotingUtils.HandleVote(voteData, suspectPlayerId); + + __instance.SetDirtyBit(1U); + __instance.CheckForEndVoting(); + + if (voteData.VotesRemaining != 0) + { + return false; + } + + __instance.playerStates.First(x => x.TargetPlayerId == srcPlayerId).SetVote(suspectPlayerId); + PlayerControl.LocalPlayer.RpcSendChatNote(srcPlayerId, ChatNoteTypes.DidVote); + + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(MeetingHud.CmdCastVote))] + public static void CmdCastVotePatch(MeetingHud __instance, byte playerId, byte suspectIdx) + { + var plr = GameData.Instance.GetPlayerById(playerId); + var voteData = plr.Object.GetVoteData(); + + if (!AmongUsClient.Instance.AmHost) + { + if (voteData == null || voteData.VotesRemaining == 0 || + voteData.VotedFor(suspectIdx)) + { + return; + } + + VotingUtils.HandleVote(voteData, suspectIdx); + + if (voteData.VotesRemaining == 0) + { + __instance.playerStates.First(x => x.TargetPlayerId == playerId).SetVote(suspectIdx); + } + } + + if (PlayerControl.LocalPlayer.PlayerId != playerId) + { + return; + } + + SoundManager.Instance.PlaySound(__instance.VoteLockinSound, false); + + foreach (var playerVoteArea in __instance.playerStates) + { + playerVoteArea.ClearButtons(); + } + + __instance.SkipVoteButton.ClearButtons(); + + var localVoteData = PlayerControl.LocalPlayer.GetVoteData(); + if (localVoteData == null || localVoteData.VotesRemaining != 0) return; + + __instance.SkipVoteButton.voteComplete = true; + __instance.SkipVoteButton.gameObject.SetActive(false); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.CheckForEndVoting))] + public static bool EndCheck(MeetingHud __instance) + { + var shouldEnd = !Helpers.GetAlivePlayers().Exists(plr => plr.GetVoteData().VotesRemaining > 0); + + var checkEndEvent = new CheckForEndVotingEvent(shouldEnd); + MiraEventManager.InvokeEvent(checkEndEvent); + shouldEnd = checkEndEvent.ForceEndVoting || checkEndEvent is { IsCancelled: false, IsVotingComplete: true }; + + if (!shouldEnd) + { + return false; + } + + var votes = VotingUtils.CalculateVotes(); + var exiled = VotingUtils.GetExiled(votes, out var isTie); + + var @event = new ProcessVotesEvent(votes, exiled); + MiraEventManager.InvokeEvent(@event); + + if (@event.VotesModified) + { + votes = @event.Votes; + exiled = VotingUtils.GetExiled(votes, out isTie); + } + + if (@event.ExiledPlayerModified) + { + exiled = @event.ExiledPlayer; + } + + var voterStates = new Il2CppStructArray([ + .. votes.Select( + v=> new MeetingHud.VoterState + { + VoterId = v.Voter, + VotedForId = v.Suspect, + }) + ]); + + __instance.RpcVotingComplete(voterStates, exiled, isTie); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.PopulateResults))] + public static bool PopulateResultsPatch(MeetingHud __instance, ref Il2CppStructArray states) + { + var votes = states.Select(x=> new CustomVote(x.VoterId, x.VotedForId)).ToList(); + var @event = new PopulateResultsEvent(votes); + MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) + { + return false; + } + + VotingUtils.HandlePopulateResults(votes); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.Confirm))] + public static bool ConfirmPatch(MeetingHud __instance, [HarmonyArgument(0)] byte suspect) + { + __instance.CmdCastVote(PlayerControl.LocalPlayer.PlayerId, suspect); + + return false; + } +} diff --git a/MiraAPI/Utilities/Extensions.cs b/MiraAPI/Utilities/Extensions.cs index 009be7f..4139aee 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -7,6 +7,7 @@ using HarmonyLib; using MiraAPI.GameOptions; using MiraAPI.Networking; using MiraAPI.Roles; +using MiraAPI.Voting; using Reactor.Utilities; using TMPro; using UnityEngine; @@ -74,6 +75,71 @@ public static class Extensions return Mathf.Approximately(number, Mathf.Round(number)); } + /// + /// Gets a cache of player's vote data components to improve performance. + /// + public static Dictionary VoteDataComponents { get; } = []; + + /// + /// Gets the PlayerVoteData of a player. + /// + /// The PlayerControl object. + /// A PlayerVoteData if there is one, null otherwise. + public static PlayerVoteData GetVoteData(this PlayerControl player) + { + if (VoteDataComponents.TryGetValue(player, out var component)) + { + return component; + } + + component = player.GetComponent(); + if (!component) + { + throw new InvalidOperationException("PlayerVoteData is not attached to the player."); + } + + VoteDataComponents[player] = component; + return component; + } + + public static KeyValuePair MaxPair(this Dictionary self, out bool tie) + { + tie = true; + var result = new KeyValuePair(byte.MaxValue, int.MinValue); + foreach (var keyValuePair in self) + { + if (keyValuePair.Value > result.Value) + { + result = keyValuePair; + tie = false; + } + else if (keyValuePair.Value == result.Value) + { + tie = true; + } + } + return result; + } + + public static KeyValuePair MaxPair(this Dictionary self, out bool tie) + { + tie = true; + var result = new KeyValuePair(byte.MaxValue, int.MinValue); + foreach (var keyValuePair in self) + { + if (keyValuePair.Value > result.Value) + { + result = keyValuePair; + tie = false; + } + else if (Math.Abs(keyValuePair.Value - result.Value) < .05) + { + tie = true; + } + } + return result; + } + /// /// Gets the best constructor for a type based on the specified arguments. /// diff --git a/MiraAPI/Voting/CustomVote.cs b/MiraAPI/Voting/CustomVote.cs new file mode 100644 index 0000000..b36796a --- /dev/null +++ b/MiraAPI/Voting/CustomVote.cs @@ -0,0 +1,8 @@ +namespace MiraAPI.Voting; + +/// +/// Represents a custom vote. +/// +/// The player that voted. +/// The suspect being voted for. +public record struct CustomVote(byte Voter, byte Suspect); diff --git a/MiraAPI/Voting/PlayerVoteData.cs b/MiraAPI/Voting/PlayerVoteData.cs new file mode 100644 index 0000000..b7a1b84 --- /dev/null +++ b/MiraAPI/Voting/PlayerVoteData.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace MiraAPI.Voting; + +/// +/// Handles player votes, and removing/adding additional votes. +/// +[RegisterInIl2Cpp] +public class PlayerVoteData(nint cppPtr) : MonoBehaviour(cppPtr) +{ + /// + /// Gets the owner of this component. + /// + public PlayerControl Owner { get; private set; } = null!; + + /// + /// Gets the list of votes the owner has. + /// + public List Votes { get; private set; } = []; + + /// + /// Gets or sets the amount of votes the owner has left. + /// + public int VotesRemaining + { + get => _votesRemaining; + set => _votesRemaining = Math.Max(0, value); + } + + private int _votesRemaining = 1; + + private void Awake() + { + Owner = GetComponent(); + + if (Owner == null) + { + Destroy(this); + } + } + + /// + /// Returns whether the owner has voted for the specified player. + /// + /// The target's playerId. + /// True if the owner voted for the specified player, false otherwise. + public bool VotedFor(byte playerId) + { + return Votes.Exists(x => x.Suspect == playerId); + } + + /// + /// Adds the voted player to the list. + /// + /// The target's playerId. + public void VoteForPlayer(byte playerId) + { + Votes.Add(new CustomVote(Owner.PlayerId, playerId)); + } + + /// + /// Removes the specified vote. + /// + /// The vote you would like to remove. + public void RemovePlayerVote(CustomVote vote) + { + Votes.Remove(vote); + } + + /// + /// Removes a single vote from the owner. + /// + /// The target's playerId. + public void RemovePlayerVote(byte playerId) + { + Votes.Remove(Votes.Find(x=>x.Suspect==playerId)); + } + + /// + /// Sets the player's votes remaining. + /// + /// The amount of votes you would like to set it to. + public void SetRemainingVotes(int votesRemaining) + { + VotesRemaining = votesRemaining; + } + + /// + /// Adds votes to the owner. + /// + /// The amount of votes you would like to add. + public void IncreaseRemainingVotes(int amount) + { + SetRemainingVotes(VotesRemaining + amount); + } + + /// + /// Removes votes from the owner. + /// + /// The amount of votes you would like to remove. + public void DecreaseRemainingVotes(int amount) + { + SetRemainingVotes(VotesRemaining - amount); + } +} diff --git a/MiraAPI/Voting/VotingUtils.cs b/MiraAPI/Voting/VotingUtils.cs new file mode 100644 index 0000000..56833a6 --- /dev/null +++ b/MiraAPI/Voting/VotingUtils.cs @@ -0,0 +1,180 @@ +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Utilities; + +namespace MiraAPI.Voting; + +/// +/// Utilities used for the Mira voting system. +/// +public static class VotingUtils +{ + private const byte SkipVoteId = 253; + + /// + /// Gets the exiled player from the list of votes. Returns null if no player is to be exiled. + /// + /// >The list of votes to check. + /// Whether the vote is a tie. + /// The player to be exiled. Will be null if no player is to be exiled. + public static NetworkedPlayerInfo? GetExiled(List votes, out bool isTie) + { + var max = CalculateNumVotes(votes).MaxPair(out var tie); + isTie = tie; + var exiled = GameData.Instance.AllPlayers.ToArray().FirstOrDefault(v => !tie && v.PlayerId == max.Key); + + if (exiled is null || exiled.IsDead || exiled.Disconnected) + { + exiled = null; + } + + return exiled; + } + + /// + /// Handles when a vote is added and allows for other mods to override/modify. + /// + /// The player's vote data. + /// Who the player voted for. + public static void HandleVote(PlayerVoteData voteData, byte suspectIdx) + { + var @event = new HandleVoteEvent(voteData, suspectIdx); + MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) + { + return; + } + + voteData.DecreaseRemainingVotes(1); + voteData.VoteForPlayer(suspectIdx); + } + + /// + /// Networks the removal of votes. Used to remove votes when a player disconnects. + /// + /// The player who is sending the RPC. Should be the host. + /// The player who voted. + /// The player who the voter voted for. + [MethodRpc((uint)MiraRpc.RemoveVote)] + public static void RpcRemoveVote(PlayerControl source, byte voterId, byte votedFor) + { + if (source.OwnerId != AmongUsClient.Instance.HostId) + { + return; + } + + MeetingHud.Instance.playerStates.First(state => state.TargetPlayerId == voterId).UnsetVote(); + + if (PlayerControl.LocalPlayer.PlayerId != voterId) + { + return; + } + + MeetingHud.Instance.playerStates.First(state => state.TargetPlayerId == votedFor).ThumbsDown.enabled = false; + + if (!AmongUsClient.Instance.AmHost) + { + var voteData = PlayerControl.LocalPlayer.GetVoteData(); + voteData.DecreaseRemainingVotes(1); + voteData.RemovePlayerVote(votedFor); + } + + foreach (var t in MeetingHud.Instance.playerStates) + { + t.voteComplete = false; + } + + MeetingHud.Instance.SkipVoteButton.voteComplete = false; + MeetingHud.Instance.SkipVoteButton.gameObject.SetActive(true); + } + + /// + /// Calculates the total number of votes. + /// + /// A list of calculated votes. + /// The total votes. + public static Dictionary CalculateNumVotes(IEnumerable votes) + { + var dictionary = new Dictionary(); + + foreach (var vote in votes.Select(v=>v.Suspect)) + { + if (!dictionary.TryAdd(vote, 1)) + { + dictionary[vote] += 1; + } + } + + return dictionary; + } + + /// + /// Calculates votes to check if all players have voted. + /// + /// The list of votes. + public static List CalculateVotes() + { + return + [ + .. Helpers.GetAlivePlayers() + .SelectMany(player => player.GetVoteData().Votes) + ]; + } + + /// + /// Handles the populating of results locally. Called by the PopulateResultsRpc. + /// + /// The list of networked votes. + public static void HandlePopulateResults(List votes) + { + PopulateResultsEvent @event = new PopulateResultsEvent(votes); + MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) + { + return; + } + + MeetingHud.Instance.TitleText.text = + DestroyableSingleton.Instance.GetString( + StringNames.MeetingVotingResults, + Il2CppSystem.Array.Empty()); + + var delays = new Dictionary(); + var num = 0; + + for (var i = 0; i < MeetingHud.Instance.playerStates.Length; i++) + { + var playerVoteArea = MeetingHud.Instance.playerStates[i]; + playerVoteArea.ClearForResults(); + foreach (var vote in votes) + { + var playerById = GameData.Instance.GetPlayerById(vote.Voter); + if (playerById == null) + { + Logger.Error($"Couldn't find player info for voter: {vote.Voter}"); + } + else if (i == 0 && vote.Suspect == SkipVoteId) + { + MeetingHud.Instance.BloopAVoteIcon(playerById, num, MeetingHud.Instance.SkippedVoting.transform); + num++; + } + else if (vote.Suspect == playerVoteArea.TargetPlayerId) + { + if (!delays.TryAdd(vote.Suspect, 0)) + { + delays[vote.Suspect]++; + } + + MeetingHud.Instance.BloopAVoteIcon(playerById, delays[vote.Suspect], playerVoteArea.transform); + } + } + } + } +}