From 989b51127630d84efc39532b53167c71cd4eec00 Mon Sep 17 00:00:00 2001 From: ang-xd Date: Fri, 28 Feb 2025 23:19:34 -0500 Subject: [PATCH] Voting "API" --- MiraAPI.Example/ExampleEventHandlers.cs | 50 +++- MiraAPI.Example/Roles/MayorRole.cs | 14 ++ .../Events/Vanilla/Meeting/ReportBodyEvent.cs | 37 +++ .../Vanilla/Meeting/StartMeetingEvent.cs | 29 +-- .../Vanilla/Meeting/Voting/HandleVoteEvent.cs | 45 ++++ .../Meeting/Voting/MeetingSelectEvent.cs | 37 +++ MiraAPI/Networking/MiraRpc.cs | 2 + ...gEventPatch.cs => ReportBodyEventPatch.cs} | 4 +- MiraAPI/Patches/PlayerControlPatches.cs | 12 +- MiraAPI/Patches/Voting/MeetingHudPatches.cs | 219 ++++++++++++++++++ MiraAPI/Utilities/Extensions.cs | 66 ++++++ MiraAPI/Voting/CustomVote.cs | 19 ++ MiraAPI/Voting/PlayerVoteData.cs | 90 +++++++ MiraAPI/Voting/PopulateResultsRpc.cs | 63 +++++ MiraAPI/Voting/VotingUtils.cs | 151 ++++++++++++ 15 files changed, 807 insertions(+), 31 deletions(-) create mode 100644 MiraAPI.Example/Roles/MayorRole.cs create mode 100644 MiraAPI/Events/Vanilla/Meeting/ReportBodyEvent.cs create mode 100644 MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs create mode 100644 MiraAPI/Events/Vanilla/Meeting/Voting/MeetingSelectEvent.cs rename MiraAPI/Patches/Events/{StartMeetingEventPatch.cs => ReportBodyEventPatch.cs} (83%) create mode 100644 MiraAPI/Patches/Voting/MeetingHudPatches.cs create mode 100644 MiraAPI/Voting/CustomVote.cs create mode 100644 MiraAPI/Voting/PlayerVoteData.cs create mode 100644 MiraAPI/Voting/PopulateResultsRpc.cs create mode 100644 MiraAPI/Voting/VotingUtils.cs diff --git a/MiraAPI.Example/ExampleEventHandlers.cs b/MiraAPI.Example/ExampleEventHandlers.cs index e2368f7..91d7899 100644 --- a/MiraAPI.Example/ExampleEventHandlers.cs +++ b/MiraAPI.Example/ExampleEventHandlers.cs @@ -1,9 +1,15 @@ -using MiraAPI.Events; +using System.Linq; +using HarmonyLib; +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 +35,39 @@ public static class ExampleEventHandlers }); } + [RegisterEvent] + public static void StartMeetingEvent(StartMeetingEvent _) + { + foreach (var plr in PlayerControl.AllPlayerControls) + { + if (plr.Data.Role is MayorRole) + { + plr.GetVoteData().AddVotes(1); + } + } + } + + [RegisterEvent(15)] + public static void HandleVoteEvent(HandleVoteEvent @event) + { + if (@event.VoteData.Owner?.Data.Role is not NeutralKillerRole) return; + + @event.VoteData.SetVotesRemaining(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().VotedPlayers.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 +81,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/ReportBodyEvent.cs b/MiraAPI/Events/Vanilla/Meeting/ReportBodyEvent.cs new file mode 100644 index 0000000..a641736 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/ReportBodyEvent.cs @@ -0,0 +1,37 @@ +namespace MiraAPI.Events.Vanilla.Meeting; + +/// +/// The event that is invoked when a body is reported. +/// This is invoked in CmdReportBody. +/// +public class ReportBodyEvent : MiraCancelableEvent +{ + /// + /// Gets the player that is reporting the body. + /// + public PlayerControl Reporter { get; } + + /// + /// Gets the player that is being reported. Will be null for emergency meeting. + /// + public NetworkedPlayerInfo? Target { get; } + + /// + /// Gets the body that is being reported. Will be null for emergency meeting. + /// + public DeadBody? Body { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The player who reported the body. + /// The player being reported. + /// The body being reported. + public ReportBodyEvent(PlayerControl reporter, NetworkedPlayerInfo? target, DeadBody? body) + { + Reporter = reporter; + Target = target; + Body = body; + } +} + diff --git a/MiraAPI/Events/Vanilla/Meeting/StartMeetingEvent.cs b/MiraAPI/Events/Vanilla/Meeting/StartMeetingEvent.cs index b988b2f..78e58f3 100644 --- a/MiraAPI/Events/Vanilla/Meeting/StartMeetingEvent.cs +++ b/MiraAPI/Events/Vanilla/Meeting/StartMeetingEvent.cs @@ -1,35 +1,22 @@ namespace MiraAPI.Events.Vanilla.Meeting; /// -/// The event that is invoked when a meeting is called. +/// The event that is invoked when a meeting is called. This event is not cancelable. +/// This event is called after Mira resets votes, so if you plan on adding votes to a specific player, do it with this event. /// -public class StartMeetingEvent : MiraCancelableEvent +public class StartMeetingEvent : MiraEvent { /// - /// Gets the player that is calling the meeting. + /// Gets the MeetingHud instance. /// - public PlayerControl Reporter { get; } - - /// - /// Gets the player that is being reported. Will be null for emergency meeting. - /// - public NetworkedPlayerInfo? Target { get; } - - /// - /// Gets the body that is being reported. Will be null for emergency meeting. - /// - public DeadBody? Body { get; } + public MeetingHud MeetingHud { get; } /// /// Initializes a new instance of the class. /// - /// The player who reported the body. - /// The player being reported. - /// The body being reported. - public StartMeetingEvent(PlayerControl reporter, NetworkedPlayerInfo? target, DeadBody? body) + /// The MeetingHud instance. + public StartMeetingEvent(MeetingHud meetingHud) { - Reporter = reporter; - Target = target; - Body = body; + MeetingHud = meetingHud; } } diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs new file mode 100644 index 0000000..9dc9659 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs @@ -0,0 +1,45 @@ +using MiraAPI.Utilities; +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..58f6eeb --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/MeetingSelectEvent.cs @@ -0,0 +1,37 @@ +using MiraAPI.Utilities; +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// The event that is invoked when the player tries to select another player to vote. If you cancel this, the player will not be selectable. +/// +public class MeetingSelectEvent : MiraCancelableEvent +{ + /// + /// 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; } + + /// + /// Initializes a new instance of the class. + /// + /// The voter's data. + /// The target's playerId. + public MeetingSelectEvent(PlayerVoteData playerVoteData, int targetId) + { + VoteData = playerVoteData; + TargetId = targetId; + TargetPlayerInfo = GameData.Instance.GetPlayerById((byte)targetId); + } +} diff --git a/MiraAPI/Networking/MiraRpc.cs b/MiraAPI/Networking/MiraRpc.cs index 280fa7e..1a3fa66 100644 --- a/MiraAPI/Networking/MiraRpc.cs +++ b/MiraAPI/Networking/MiraRpc.cs @@ -39,4 +39,6 @@ public enum MiraRpc : uint /// Custom Game Over RPC. /// CustomGameOver, + PopulateResults, + RemoveVote, } diff --git a/MiraAPI/Patches/Events/StartMeetingEventPatch.cs b/MiraAPI/Patches/Events/ReportBodyEventPatch.cs similarity index 83% rename from MiraAPI/Patches/Events/StartMeetingEventPatch.cs rename to MiraAPI/Patches/Events/ReportBodyEventPatch.cs index 2012b5d..feba1c9 100644 --- a/MiraAPI/Patches/Events/StartMeetingEventPatch.cs +++ b/MiraAPI/Patches/Events/ReportBodyEventPatch.cs @@ -6,7 +6,7 @@ using MiraAPI.Utilities; namespace MiraAPI.Patches.Events; [HarmonyPatch] -public static class StartMeetingEventPatch +public static class ReportBodyEventPatch { [HarmonyPrefix] [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.CmdReportDeadBody))] @@ -14,7 +14,7 @@ public static class StartMeetingEventPatch { var body = target != null ? Helpers.GetBodyById(target.PlayerId) : null; - var @event = new StartMeetingEvent(__instance, target, body); + var @event = new ReportBodyEvent(__instance, target, body); MiraEventManager.InvokeEvent(@event); return !@event.IsCancelled; } diff --git a/MiraAPI/Patches/PlayerControlPatches.cs b/MiraAPI/Patches/PlayerControlPatches.cs index b6fd6b8..6036209 100644 --- a/MiraAPI/Patches/PlayerControlPatches.cs +++ b/MiraAPI/Patches/PlayerControlPatches.cs @@ -6,6 +6,8 @@ using MiraAPI.Events.Vanilla.Player; using MiraAPI.Hud; using MiraAPI.Modifiers; using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Voting; using Reactor.Utilities.Extensions; namespace MiraAPI.Patches; @@ -24,12 +26,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/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs new file mode 100644 index 0000000..f3db9cd --- /dev/null +++ b/MiraAPI/Patches/Voting/MeetingHudPatches.cs @@ -0,0 +1,219 @@ +using System.Linq; +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.Utilities; +using MiraAPI.Voting; +using Reactor.Networking.Rpc; + +namespace MiraAPI.Patches.Voting; + +[HarmonyPatch(typeof(MeetingHud))] +public 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.VotedPlayers.Clear(); + voteData.SetVotesRemaining(1); + + if (plr.Data.IsDead || plr.Data.Disconnected) + { + voteData.SetVotesRemaining(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 @event = new MeetingSelectEvent(voteData, suspectStateIdx); + MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) + { + return false; + } + + var hasVotes = voteData.VotesRemaining > 0; + var hasVotedFor = voteData.VotedPlayers.Contains((byte)suspectStateIdx); + + return hasVotes && !hasVotedFor; + } + + [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.VotedPlayers.Contains(pc.PlayerId)) + { + continue; + } + + voteData.VotedPlayers.Remove(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.VotedPlayers.Contains(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.VotedPlayers.Contains(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) + { + if (Helpers.GetAlivePlayers().Any(plr => plr.GetVoteData()!.VotesRemaining > 0)) + { + return false; + } + + var votes = VotingUtils.CalculateVotes(); + + var max = VotingUtils.CalculateNumVotes(votes).MaxPair(out var isTie); + var exiled = GameData.Instance.AllPlayers.ToArray().FirstOrDefault(v => !isTie && v.PlayerId == max.Key); + + if (exiled is null || exiled.IsDead || exiled.Disconnected) + { + exiled = null; + } + + __instance.RpcVotingComplete(new MeetingHud.VoterState[__instance.playerStates.Length], exiled, isTie); + + return false; + } + + + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.PopulateResults))] + public static bool PopulateResultsPatch(MeetingHud __instance) + { + if (!AmongUsClient.Instance.AmHost) + { + return false; + } + var votes = VotingUtils.CalculateVotes(); + + Rpc.Instance.Send(votes.ToArray()); + 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 0a4fee3..44f6ef3 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -6,6 +6,7 @@ using HarmonyLib; using MiraAPI.GameOptions; using MiraAPI.Networking; using MiraAPI.Roles; +using MiraAPI.Voting; using Reactor.Utilities; using TMPro; using UnityEngine; @@ -39,6 +40,71 @@ public static class Extensions BitConverter.GetBytes(count.Value).AddRangeToArray(BitConverter.GetBytes(chance.Value))); } + /// + /// 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..5a5a08a --- /dev/null +++ b/MiraAPI/Voting/CustomVote.cs @@ -0,0 +1,19 @@ +namespace MiraAPI.Voting; + +/// +/// A custom implementation to keep track of votes. +/// +/// The player who voted. +/// The suspect. +public readonly struct CustomVote(byte voter, byte suspect) +{ + /// + /// Gets the playerId of the Voter. + /// + public byte Voter { get; } = voter; + + /// + /// Gets the playerId of the Suspect. + /// + public byte Suspect { get; } = suspect; +} diff --git a/MiraAPI/Voting/PlayerVoteData.cs b/MiraAPI/Voting/PlayerVoteData.cs new file mode 100644 index 0000000..0b0f099 --- /dev/null +++ b/MiraAPI/Voting/PlayerVoteData.cs @@ -0,0 +1,90 @@ +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 ptr) : MonoBehaviour(ptr) +{ + /// + /// Gets the owner of this component. + /// + public PlayerControl? Owner { get; private set; } + + /// + /// Gets the players which the owner has voted for. + /// + public List VotedPlayers { 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); + } + } + + /// + /// Adds the voted player to the list. + /// + /// The target's playerId. + public void VoteForPlayer(byte playerId) + { + VotedPlayers.Add(playerId); + } + + /// + /// Removes the voted player from the list. + /// + /// The target's playerId. + public void RemovePlayerVote(byte playerId) + { + if (!VotedPlayers.Contains(playerId)) return; + VotedPlayers.Remove(playerId); + } + + /// + /// Sets the player's votes remaining. + /// + /// The amount of votes you would like to set it to. + public void SetVotesRemaining(int votesRemaining) + { + VotesRemaining = votesRemaining; + } + + /// + /// Adds votes to the owner. + /// + /// The amount of votes you would like to add. + public void AddVotes(int amount) + { + SetVotesRemaining(VotesRemaining + amount); + } + + /// + /// Removes votes from the owner. + /// + /// The amount of votes you would like to remove. + public void RemoveVotes(int amount) + { + SetVotesRemaining(VotesRemaining - amount); + } +} diff --git a/MiraAPI/Voting/PopulateResultsRpc.cs b/MiraAPI/Voting/PopulateResultsRpc.cs new file mode 100644 index 0000000..ea4963f --- /dev/null +++ b/MiraAPI/Voting/PopulateResultsRpc.cs @@ -0,0 +1,63 @@ +using System.Linq; +using Hazel; +using MiraAPI.Networking; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; + +namespace MiraAPI.Voting; + +/// +/// Handles the networking for populating results of a meeting. Custom implementation to work with Mira voting API. +/// +[RegisterCustomRpc((uint)MiraRpc.PopulateResults)] +public class PopulateResultsRpc(MiraApiPlugin plugin, uint id) : PlayerCustomRpc(plugin, id) +{ + /// + public override RpcLocalHandling LocalHandling => RpcLocalHandling.Before; + + /// + public override void Write(MessageWriter writer, CustomVote[]? data) + { + if (data == null) + { + writer.WritePacked(0); + return; + } + + writer.WritePacked(data.Length); + foreach (var t in data) + { + writer.Write(t.Voter); + writer.Write(t.Suspect); + } + } + + /// + public override CustomVote[] Read(MessageReader reader) + { + var votes = new CustomVote[reader.ReadPackedInt32()]; + + for (var i = 0; i < votes.Length; i++) + { + votes[i] = new CustomVote(reader.ReadByte(), reader.ReadByte()); + } + + return votes; + } + + /// + public override void Handle(PlayerControl innerNetObject, CustomVote[]? data) + { + if (AmongUsClient.Instance.HostId != innerNetObject.OwnerId) + { + return; + } + + if (data == null) + { + return; + } + + VotingUtils.HandlePopulateResults([.. data]); + } +} diff --git a/MiraAPI/Voting/VotingUtils.cs b/MiraAPI/Voting/VotingUtils.cs new file mode 100644 index 0000000..d850226 --- /dev/null +++ b/MiraAPI/Voting/VotingUtils.cs @@ -0,0 +1,151 @@ +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; + + /// + /// 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) + { + HandleVoteEvent @event = new HandleVoteEvent(voteData, suspectIdx); + MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) + { + return; + } + + voteData.RemoveVotes(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.RemoveVotes(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) + { + if (!dictionary.TryAdd(vote.Suspect, 1)) + { + dictionary[vote.Suspect] += 1; + } + } + + return dictionary; + } + + /// + /// Calculates votes to check if all players have voted. + /// + /// The list of votes. + public static List CalculateVotes() + { + return (from player in Helpers.GetAlivePlayers() + from vote in player.GetVoteData()!.VotedPlayers + select new CustomVote(player.PlayerId, vote)).ToList(); + } + + /// + /// Handles the populating of results locally. Called by the PopulateResultsRpc. + /// + /// The list of networked votes. + public static void HandlePopulateResults(List votes) + { + 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); + } + } + } + } +}