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);
+ }
+ }
+}