From af9e943ef1543e27140b12463d9eac0e115b1a0d Mon Sep 17 00:00:00 2001 From: XaineDev <125938059+XaineDev@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:37:02 +0100 Subject: [PATCH] feat(roles): assign standalone ghost roles on death from their lobby count and chance --- MiraAPI/Networking/MiraRpc.cs | 5 ++ MiraAPI/Patches/Roles/RoleManagerPatches.cs | 15 ++-- MiraAPI/Roles/CustomRoleConfiguration.cs | 2 + MiraAPI/Roles/GhostRoleAssignment.cs | 78 +++++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 MiraAPI/Roles/GhostRoleAssignment.cs diff --git a/MiraAPI/Networking/MiraRpc.cs b/MiraAPI/Networking/MiraRpc.cs index 836e822..91f632c 100644 --- a/MiraAPI/Networking/MiraRpc.cs +++ b/MiraAPI/Networking/MiraRpc.cs @@ -84,4 +84,9 @@ public enum MiraRpc : uint /// Custom RPC to sync the Gamemode option. /// SyncGamemodeOption, + + /// + /// Host gives a dead player a custom ghost role on every client. + /// + SetGhostRole, } diff --git a/MiraAPI/Patches/Roles/RoleManagerPatches.cs b/MiraAPI/Patches/Roles/RoleManagerPatches.cs index 82e937f..c417cef 100644 --- a/MiraAPI/Patches/Roles/RoleManagerPatches.cs +++ b/MiraAPI/Patches/Roles/RoleManagerPatches.cs @@ -87,24 +87,31 @@ public static void ModifierSelectionPatches() [HarmonyPrefix] [HarmonyPatch(nameof(RoleManager.AssignRoleOnDeath))] - public static bool AssignRoleOnDeath([HarmonyArgument(0)] PlayerControl plr) + public static bool AssignRoleOnDeath([HarmonyArgument(0)] PlayerControl plr, [HarmonyArgument(1)] bool specialRolesAllowed) { if (!plr || !plr.Data.IsDead) { return false; } - if (plr.Data.Role is not ICustomRole role) + if (plr.Data.Role is ICustomRole role && role.Configuration.GhostRole is not (RoleTypes.CrewmateGhost or RoleTypes.ImpostorGhost)) + { + plr.RpcSetRole(role.Configuration.GhostRole); + return false; + } + + if (!specialRolesAllowed || !AmongUsClient.Instance.AmHost || RoleManager.IsGhostRole(plr.Data.Role.Role)) { return true; } - if (role.Configuration.GhostRole is RoleTypes.CrewmateGhost or RoleTypes.ImpostorGhost) + var ghostRole = GhostRoleAssignment.Roll(plr.Data.Role.IsImpostor); + if (ghostRole == null) { return true; } - plr.RpcSetRole(role.Configuration.GhostRole); + GhostRoleAssignment.RpcSetGhostRole(PlayerControl.LocalPlayer, plr, (ushort)ghostRole.Value); return false; } } diff --git a/MiraAPI/Roles/CustomRoleConfiguration.cs b/MiraAPI/Roles/CustomRoleConfiguration.cs index 46fc3bd..b975143 100644 --- a/MiraAPI/Roles/CustomRoleConfiguration.cs +++ b/MiraAPI/Roles/CustomRoleConfiguration.cs @@ -144,6 +144,8 @@ public CustomRoleConfiguration(ICustomRole role) /// /// Gets or sets a value indicating whether the role should show up in the Role Options menu. + /// Defaults to true for ghost roles. A ghost role that turns it off is also handed out on death from its count and + /// chance, like vanilla's Guardian Angel and Spirit Guide. /// public bool HideSettings { get; set; } diff --git a/MiraAPI/Roles/GhostRoleAssignment.cs b/MiraAPI/Roles/GhostRoleAssignment.cs new file mode 100644 index 0000000..7ba6e09 --- /dev/null +++ b/MiraAPI/Roles/GhostRoleAssignment.cs @@ -0,0 +1,78 @@ +using System.Linq; +using AmongUs.GameOptions; +using MiraAPI.Networking; +using Reactor.Networking.Attributes; + +namespace MiraAPI.Roles; + +/// +/// Hands out custom ghost roles (a role whose is true) when a player dies, from the +/// role's lobby count and chance, the way vanilla hands out Guardian Angel and Spirit Guide. Only ghost roles with +/// turned off take part; ghost roles are hidden by default because +/// most are reached through another role's instead. Freeplay assigns +/// any ghost role whose count is above zero, so enabling one in the lobby is enough to test it. +/// +internal static class GhostRoleAssignment +{ + public static RoleTypes? Roll(bool impostor) + { + var roleOptions = GameOptionsManager.Instance.CurrentGameOptions.RoleOptions; + var freeplay = AmongUsClient.Instance.NetworkMode == NetworkModes.FreePlay; + + var candidates = CustomRoleManager.CustomRoleBehaviours + .Where(role => role.IsDead && role.IsImpostor == impostor && CustomRoleUtils.CanSpawnOnCurrentMode(role)) + .Select(role => role.Role) + .OrderBy(_ => HashRandom.Next(int.MaxValue)); + + foreach (var role in candidates) + { + var maxCount = roleOptions.GetNumPerGame(role); + if (maxCount <= 0) + { + continue; + } + + if (freeplay) + { + return role; + } + + var held = PlayerControl.AllPlayerControls.ToArray() + .Count(player => player != null && player.Data != null && player.Data.Role != null && player.Data.Role.Role == role); + if (held < maxCount && HashRandom.Next(101) < roleOptions.GetChancePerGame(role)) + { + return role; + } + } + + return null; + } + + // What vanilla's CoSetRole does for a ghost role, run on every client from the host's decision. + [MethodRpc((uint)MiraRpc.SetGhostRole)] + public static void RpcSetGhostRole(PlayerControl sender, PlayerControl target, ushort roleId) + { + if (sender == null || AmongUsClient.Instance == null || sender.OwnerId != AmongUsClient.Instance.HostId) + { + return; + } + + if (target == null || target.Data == null || target.Data.Disconnected) + { + return; + } + + var role = (RoleTypes)roleId; + if (!RoleManager.InstanceExists || !RoleManager.IsGhostRole(role)) + { + return; + } + + RoleManager.Instance.SetRole(target, role); + target.Data.Role.SpawnTaskHeader(target); + if (target == PlayerControl.LocalPlayer && HudManager.InstanceExists) + { + HudManager.Instance.ReportButton.gameObject.SetActive(false); + } + } +}