From 24cea3a630ba57e0a6344d4844ad88f5454e9f59 Mon Sep 17 00:00:00 2001
From: PHGaming121 <76562427+PHGaming121@users.noreply.github.com>
Date: Wed, 22 Apr 2026 03:02:13 -0300
Subject: [PATCH] add 2 roles made by dechis (#592)
* add 3 roles made by dechis
* remove solarist
dechis asked me to remove this one since it wasnt FULLY working(causing some issues in online games)
* fix some option menu stuff
---
CTA/CustomTeamAssigner/CustomRoles.cs | 3 +
CustomRoles.cs | 2 +
Main.cs | 2 +
Modules/CustomRolesHelper.cs | 7 +
Modules/GameState.cs | 3 +-
Resources/Lang/en_US.jsonc | 50 ++-
Resources/Lang/pt_BR.jsonc | 2 +-
.../Standard/Impostor/Support/Chainbinder.cs | 292 ++++++++++++++++
Roles/Standard/Neutral/Killing/Jackpot.cs | 314 ++++++++++++++++++
9 files changed, 671 insertions(+), 4 deletions(-)
create mode 100644 Roles/Standard/Impostor/Support/Chainbinder.cs
create mode 100644 Roles/Standard/Neutral/Killing/Jackpot.cs
diff --git a/CTA/CustomTeamAssigner/CustomRoles.cs b/CTA/CustomTeamAssigner/CustomRoles.cs
index ff9289d64..8248a8748 100644
--- a/CTA/CustomTeamAssigner/CustomRoles.cs
+++ b/CTA/CustomTeamAssigner/CustomRoles.cs
@@ -57,6 +57,7 @@ public enum CustomRoles
EvilGuesser,
EvilTracker,
Exclusionary,
+ Exorcist,
Fabricator,
Fakeshifter,
Fireworker,
@@ -64,6 +65,8 @@ public enum CustomRoles
Framer,
Freezer,
Frightener,
+ Chainbinder,
+ Jackpot,
Gambler,
Gangster,
Generator,
diff --git a/CustomRoles.cs b/CustomRoles.cs
index be2c15ab0..8248a8748 100644
--- a/CustomRoles.cs
+++ b/CustomRoles.cs
@@ -65,6 +65,8 @@ public enum CustomRoles
Framer,
Freezer,
Frightener,
+ Chainbinder,
+ Jackpot,
Gambler,
Gangster,
Generator,
diff --git a/Main.cs b/Main.cs
index fbfff8838..728e957f2 100644
--- a/Main.cs
+++ b/Main.cs
@@ -547,6 +547,7 @@ public class Main : BasePlugin
{ CustomRoles.Opportunist, "#4dff4d" },
{ CustomRoles.Vector, "#ff6201" },
{ CustomRoles.Jackal, "#00b4eb" },
+ { CustomRoles.Jackpot, "#00fd50" },
{ CustomRoles.Sidekick, "#00b4eb" },
{ CustomRoles.Innocent, "#8f815e" },
{ CustomRoles.Pelican, "#34c84b" },
@@ -1075,6 +1076,7 @@ public enum CustomWinner
Revolutionist = CustomRoles.Revolutionist,
Technician = CustomRoles.Technician,
Jackal = CustomRoles.Jackal,
+ Jackpot = CustomRoles.Jackpot,
God = CustomRoles.God,
Vector = CustomRoles.Vector,
Innocent = CustomRoles.Innocent,
diff --git a/Modules/CustomRolesHelper.cs b/Modules/CustomRolesHelper.cs
index d64515fdd..e2fb0ba6c 100644
--- a/Modules/CustomRolesHelper.cs
+++ b/Modules/CustomRolesHelper.cs
@@ -165,6 +165,8 @@ internal static class CustomRolesHelper
CustomRoles.Venerer => UsePets ? CustomRoles.Impostor : CustomRoles.Shapeshifter,
CustomRoles.Kidnapper => CustomRoles.Shapeshifter,
CustomRoles.Frightener => CustomRoles.Shapeshifter,
+ CustomRoles.Jackpot => CustomRoles.Shapeshifter,
+ CustomRoles.Chainbinder => UsePets ? CustomRoles.Impostor : CustomRoles.Shapeshifter,
CustomRoles.Exorcist => CustomRoles.Impostor,
CustomRoles.Obstructer => CustomRoles.Impostor,
CustomRoles.Ambusher => UsePets ? CustomRoles.Impostor : CustomRoles.Shapeshifter,
@@ -523,6 +525,7 @@ internal static class CustomRolesHelper
CustomRoles.Glitch => RoleTypes.Shapeshifter,
CustomRoles.Jailor => UsePets && Jailor.UsePet.GetBool() ? RoleTypes.GuardianAngel : RoleTypes.Impostor,
CustomRoles.Juggernaut => RoleTypes.Impostor,
+ CustomRoles.Jackpot => RoleTypes.Shapeshifter,
CustomRoles.Jinx => RoleTypes.Impostor,
CustomRoles.Stalker => RoleTypes.Impostor,
CustomRoles.Provocateur => RoleTypes.Impostor,
@@ -643,6 +646,7 @@ internal static class CustomRolesHelper
{
return (role == CustomRoles.Arsonist && (check || !CanCheck || !Options.IsLoaded || Arsonist.ArsonistKeepsGameGoing == null || Arsonist.ArsonistKeepsGameGoing.GetBool() || (Arsonist.ArsonistCanIgniteAnytime != null && Arsonist.ArsonistCanIgniteAnytime.GetBool()))) || role is
CustomRoles.Jackal or
+ CustomRoles.Jackpot or
CustomRoles.Glitch or
CustomRoles.Sidekick or
CustomRoles.HexMaster or
@@ -1128,6 +1132,7 @@ internal static class CustomRolesHelper
or CustomRoles.Thanos
or CustomRoles.Tiger
or CustomRoles.Bargainer
+ or CustomRoles.Jackpot
or CustomRoles.Chemist
or CustomRoles.Simon
or CustomRoles.Sharpshooter
@@ -1175,6 +1180,7 @@ internal static class CustomRolesHelper
CustomRoles.Jester => RoleOptionType.Neutral_Evil,
CustomRoles.Gaslighter => RoleOptionType.Neutral_Evil,
CustomRoles.God => RoleOptionType.Neutral_Evil,
+ CustomRoles.Jackpot => RoleOptionType.Neutral_Killing,
CustomRoles.Executioner => RoleOptionType.Neutral_Evil,
CustomRoles.Doomsayer => RoleOptionType.Neutral_Evil,
CustomRoles.Vector => RoleOptionType.Neutral_Evil,
@@ -1255,6 +1261,7 @@ internal static class CustomRolesHelper
CustomRoles.Godfather => RoleOptionType.Impostor_Support,
CustomRoles.Hypnotist => RoleOptionType.Impostor_Support,
CustomRoles.Librarian => RoleOptionType.Impostor_Support,
+ CustomRoles.Chainbinder => RoleOptionType.Impostor_Support,
CustomRoles.Nullifier => RoleOptionType.Impostor_Support,
CustomRoles.Occultist => RoleOptionType.Impostor_Support,
CustomRoles.Perplexer => RoleOptionType.Impostor_Support,
diff --git a/Modules/GameState.cs b/Modules/GameState.cs
index c557faa8b..92c2748c3 100644
--- a/Modules/GameState.cs
+++ b/Modules/GameState.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using AmongUs.GameOptions;
@@ -72,6 +72,7 @@ public class PlayerState(byte playerId)
Misguess,
LossOfBlood,
Frightened,
+ Bankrupt,
// Natural Disasters
Meteor,
diff --git a/Resources/Lang/en_US.jsonc b/Resources/Lang/en_US.jsonc
index 64ffe900b..bff117772 100644
--- a/Resources/Lang/en_US.jsonc
+++ b/Resources/Lang/en_US.jsonc
@@ -626,7 +626,7 @@
"DeputyHandcuffText": "Handcuff",
"DeputyHandcuffedPlayer": "Handcuffed target",
"DeputyInvalidTarget": "Target cannot be handcuffed",
- "Detective": "Detective",
+ "Detective": "Detective",
"DetectiveSuspectLimit": "Suspects Per Case",
"DetectiveEHR": "Detective",
"DetectiveEHRInfo": "Gather Info, Solve Murders",
@@ -1898,6 +1898,19 @@
"FrightenerInfo": "Prevent Players From Reporting",
"FrightenerInfoLong": "(Impostors):\\nAs the Frightener, you can shapeshift into a player to alarm them for some time. Alarmed players can't report bodies (or even call meetings), else they die.",
"Frightener.KillOnEmergencyMeetingCall": "Alarmed Players Die When Calling an Emergency Meeting",
+ "Chainbinder": "Chainbinder",
+ "ChainbinderInfo": "Chain nearby players together",
+ "ChainbinderInfoLong": "(Impostors):\\nAs the Chainbinder, use your ability to chain two nearby players together for a short time. When they move too far apart, they are pulled back toward each other. With shapeshift basis, the selected player becomes part of the chain; with pets enabled, the two nearest valid players around you are chained instead. You can still kill normally.",
+ "Chainbinder.NotEnoughTargets": "Not enough valid players nearby to form a chain",
+ "Chainbinder.PairBound": "Chain formed: {0} and {1}",
+ "Chainbinder.PairEnded": "Your chain ended",
+ "Chainbinder.PairSuffix": "Chain: {0} <> {1}",
+ "ChainbinderButtonText": "Chain",
+ "ChainbinderCooldown": "Chain Cooldown",
+ "ChainbinderDuration": "Chain Duration",
+ "ChainbinderBindRange": "Chain Range",
+ "ChainbinderMaxDistance": "Max Distance Before Pull",
+ "ChainbinderCanBindImpostors": "Can Chain Fellow Impostors",
"Gambler": "Gambler",
"GamblerInfo": "Each Kill gets you an Effect",
"GamblerInfoLong": "(Impostors):\\nAs the Gambler, a random event happens after each kill you get. These effects can be anywhere between a self-report and an extra sniper shot.",
@@ -2763,6 +2776,38 @@
"JackalCanRecruitMadmates": "Can recruit Madmates",
"JackalCanRecruitSidekick": "Can recruit Sidekick",
"JackalSidekick": "This Jackal-Sidekick interaction is not allowed",
+ "Jackpot": "Jackpot",
+ "JackpotInfo": "Open a gambling domain and spin for a jackpot",
+ "JackpotInfoLong": "(Neutrals):\\nAs Jackpot, use the Jackpot button to open a domain where spins happen automatically every few seconds. A miss costs money and raises your next jackpot chance. A near miss gives you a short buff and raises the next jackpot chance even more. Hitting the jackpot gives you a short burst of overwhelming power: near-instant kills, boosted speed, boosted vision, and temporary kill immunity. If you run out of money before you hit, you die.",
+ "JackpotButtonText": "Jackpot",
+ "Jackpot.StartingMoney": "Starting Money",
+ "Jackpot.MoneyPerKill": "Money Gained Per Kill",
+ "Jackpot.MissCost": "Money Lost On Miss",
+ "Jackpot.SpinInterval": "Spin Interval",
+ "Jackpot.BaseChance": "Base Jackpot Chance",
+ "Jackpot.MissChanceIncrease": "Jackpot Chance Increase Per Miss",
+ "Jackpot.NearChance": "Near Miss Chance",
+ "Jackpot.NearChanceIncrease": "Jackpot Chance Increase On Near Miss",
+ "Jackpot.NearDuration": "Near Miss Buff Duration",
+ "Jackpot.NearSpeed": "Near Miss Speed",
+ "Jackpot.NearVision": "Near Miss Vision",
+ "Jackpot.Duration": "Jackpot Duration",
+ "Jackpot.Speed": "Jackpot Speed",
+ "Jackpot.Vision": "Jackpot Vision",
+ "Jackpot.GameRulesTransmitted": "GAME RULES HAVE BEEN TRANSMITTED",
+ "Jackpot.DomainStarted": "The domain is live. Spins will roll every {0}s.",
+ "Jackpot.DomainAlreadyActive": "Your jackpot sequence is already active",
+ "Jackpot.KillReward": "+${0} from that kill. Money: ${1}",
+ "Jackpot.Spin.Miss": "MISS\\nLost ${0}. Money: ${1}. Next jackpot chance: {2}%",
+ "Jackpot.Spin.Near": "NEAR MISS\\nBuffed for {0}s. Money: ${1}. Next jackpot chance: {2}%",
+ "Jackpot.Spin.Jackpot": "JACKPOT!",
+ "Jackpot.JackpotState": "Unlimited pressure for {0}s.",
+ "Jackpot.OutOfMoney": "You ran out of money and busted.",
+ "Jackpot.JackpotEnded": "The jackpot ended. You can start spinning again.",
+ "Jackpot.Progress.Ready": "${0} | {1}% | READY",
+ "Jackpot.Progress.Spin": "${0} | {1}% | SPIN",
+ "Jackpot.Progress.Near": "${0} | {1}% | NEAR",
+ "Jackpot.Progress.Jackpot": "${0} | JACKPOT {1}s",
"Jester": "Jester",
"JesterInfo": "Get voted out",
"JesterInfoLong": "(Neutrals):\\nJester wins alone if voted out but loses if still alive at the end. Jester, Executioner, and Innocent can win together.",
@@ -9179,7 +9224,7 @@
"CommandArgs.Vote.Id": "The player's ID to vote for",
"CommandArgs.Whisper.Ids": "The players' IDs to whisper to, separated by commas (no space)",
"CommandArgs.Whisper.Message": "The message to whisper",
- "CommandArgs.XOR.Role": "A main role that will never spawn in the same game with the other role",
+ "CommandArgs.XOR.Role": "A main role that will never spawn in the same game with the other role",
// Command Descriptions
"CommandDescription.AFKExempt": "Exempt a player from being checked by the AFK detector",
"CommandDescription.Achievements": "See which achievements you have unlocked and which you haven't",
@@ -9599,6 +9644,7 @@
"DeathReason.Fall": "Fall",
"DeathReason.FollowingSuicide": "Heartbroken",
"DeathReason.Frightened": "Frightened",
+ "DeathReason.Bankrupt": "Bankrupted",
"DeathReason.Gambled": "Guessed",
"DeathReason.Hack": "Hacked",
"DeathReason.Hex": "Hexed",
diff --git a/Resources/Lang/pt_BR.jsonc b/Resources/Lang/pt_BR.jsonc
index 295b4fb2d..7903bab70 100644
--- a/Resources/Lang/pt_BR.jsonc
+++ b/Resources/Lang/pt_BR.jsonc
@@ -1,4 +1,4 @@
-{
+{
/* ==============================
CORE & FREQUENTLY USED STRINGS
============================== */
diff --git a/Roles/Standard/Impostor/Support/Chainbinder.cs b/Roles/Standard/Impostor/Support/Chainbinder.cs
new file mode 100644
index 000000000..3759efeb3
--- /dev/null
+++ b/Roles/Standard/Impostor/Support/Chainbinder.cs
@@ -0,0 +1,292 @@
+using System.Collections.Generic;
+using System.Linq;
+using AmongUs.GameOptions;
+using EHR.Modules;
+using Hazel;
+using UnityEngine;
+
+namespace EHR.Roles;
+
+using static EHR.Translator;
+
+public class Chainbinder : RoleBase
+{
+ public static bool On;
+
+ private const float PullInterval = 0.1f;
+
+ private static OptionItem AbilityCooldown;
+ private static OptionItem LinkDuration;
+ private static OptionItem BindRange;
+ private static OptionItem MaxDistance;
+ private static OptionItem CanBindImpostors;
+ private static List PlayerIdList = [];
+
+ private byte ChainbinderId;
+ private byte FirstTarget = byte.MaxValue;
+ private byte SecondTarget = byte.MaxValue;
+ private float RemainingDuration;
+ private float PullTimer;
+
+ public override bool IsEnable => On;
+
+ private bool HasLink => FirstTarget != byte.MaxValue && SecondTarget != byte.MaxValue;
+
+ public override void SetupCustomOption()
+ {
+ StartSetup(658380)
+ .AutoSetupOption(ref AbilityCooldown, 25f, new FloatValueRule(5f, 60f, 0.5f), OptionFormat.Seconds, overrideName: "ChainbinderCooldown")
+ .AutoSetupOption(ref LinkDuration, 10f, new FloatValueRule(2.5f, 60f, 0.5f), OptionFormat.Seconds, overrideName: "ChainbinderDuration")
+ .AutoSetupOption(ref BindRange, 3f, new FloatValueRule(1f, 15f, 0.5f), OptionFormat.Multiplier, overrideName: "ChainbinderBindRange")
+ .AutoSetupOption(ref MaxDistance, 2f, new FloatValueRule(0.5f, 10f, 0.5f), OptionFormat.Multiplier, overrideName: "ChainbinderMaxDistance")
+ .AutoSetupOption(ref CanBindImpostors, false, overrideName: "ChainbinderCanBindImpostors");
+ }
+
+ public override void Init()
+ {
+ On = false;
+ PlayerIdList = [];
+ ClearLocalState();
+ }
+
+ public override void Add(byte playerId)
+ {
+ On = true;
+ PlayerIdList.Add(playerId);
+ ChainbinderId = playerId;
+ ClearLocalState();
+ }
+
+ public override void Remove(byte playerId)
+ {
+ ClearLink(sync: true, refresh: false);
+ PlayerIdList.Remove(playerId);
+ On = PlayerIdList.Count > 0;
+ }
+
+ public override void ApplyGameOptions(IGameOptions opt, byte playerId)
+ {
+ if (Options.UsePhantomBasis.GetBool())
+ {
+ AURoleOptions.PhantomCooldown = AbilityCooldown.GetFloat();
+ AURoleOptions.PhantomDuration = 1f;
+ }
+ else
+ {
+ AURoleOptions.ShapeshifterCooldown = AbilityCooldown.GetFloat();
+ AURoleOptions.ShapeshifterDuration = 1f;
+ }
+ }
+
+ public override void SetButtonTexts(HudManager hud, byte id)
+ {
+ if (Options.UsePets.GetBool() && !Options.UsePhantomBasis.GetBool())
+ hud.PetButton?.OverrideText(GetString("ChainbinderButtonText"));
+ else
+ hud.AbilityButton?.OverrideText(GetString("ChainbinderButtonText"));
+ }
+
+ public override void OnPet(PlayerControl pc)
+ {
+ TryChainPlayers(pc);
+ }
+
+ public override bool OnVanish(PlayerControl pc)
+ {
+ TryChainPlayers(pc);
+ return false;
+ }
+
+ public override bool OnShapeshift(PlayerControl shapeshifter, PlayerControl target, bool shapeshifting)
+ {
+ if (shapeshifting) TryChainPlayers(shapeshifter, target);
+ return false;
+ }
+
+ public override void OnFixedUpdate(PlayerControl pc)
+ {
+ if (pc == null || pc.PlayerId != ChainbinderId || !HasLink) return;
+
+ if (!pc.IsAlive() || !GameStates.IsInTask)
+ {
+ ClearLink(sync: true, refresh: true);
+ return;
+ }
+
+ RemainingDuration -= UnityEngine.Time.fixedDeltaTime;
+ if (RemainingDuration <= 0f)
+ {
+ ClearLink(notifyBinder: true);
+ return;
+ }
+
+ PlayerControl first = Utils.GetPlayerById(FirstTarget);
+ PlayerControl second = Utils.GetPlayerById(SecondTarget);
+
+ if (!AreLinkedPlayersValid(first, second))
+ {
+ ClearLink(notifyBinder: true);
+ return;
+ }
+
+ if (first.onLadder || first.inMovingPlat || first.inVent || second.onLadder || second.inMovingPlat || second.inVent) return;
+
+ PullTimer -= UnityEngine.Time.fixedDeltaTime;
+ if (PullTimer > 0f) return;
+
+ float currentDistance = Vector2.Distance(first.Pos(), second.Pos());
+ float maxDistance = MaxDistance.GetFloat();
+
+ if (currentDistance <= maxDistance) return;
+
+ Vector2 direction = (second.Pos() - first.Pos()).normalized;
+ if (direction == Vector2.zero) return;
+
+ PullTimer = PullInterval;
+ Vector2 adjustment = direction * ((currentDistance - maxDistance) / 2f);
+
+ first.TP(first.Pos() + adjustment, log: false);
+ second.TP(second.Pos() - adjustment, log: false);
+ }
+
+ public override void OnReportDeadBody()
+ {
+ ClearLink(sync: true, refresh: true);
+ }
+
+ public override void AfterMeetingTasks()
+ {
+ ClearLink(sync: true, refresh: true);
+ }
+
+ public override string GetSuffix(PlayerControl seer, PlayerControl target, bool hud = false, bool meeting = false)
+ {
+ if (seer.PlayerId != target.PlayerId || seer.PlayerId != ChainbinderId || (seer.IsModdedClient() && !hud) || meeting) return string.Empty;
+ if (!HasLink) return string.Empty;
+
+ return string.Format(GetString("Chainbinder.PairSuffix"), FirstTarget.ColoredPlayerName(), SecondTarget.ColoredPlayerName());
+ }
+
+ public void ReceiveRPC(MessageReader reader)
+ {
+ FirstTarget = reader.ReadByte();
+ SecondTarget = reader.ReadByte();
+ RemainingDuration = HasLink ? LinkDuration.GetFloat() : 0f;
+ PullTimer = 0f;
+ }
+
+ private void TryChainPlayers(PlayerControl binder, PlayerControl requestedTarget = null)
+ {
+ if (binder == null || binder.PlayerId != ChainbinderId || !binder.IsAlive()) return;
+
+ if (!TryGetTargets(binder, requestedTarget, out PlayerControl first, out PlayerControl second))
+ {
+ binder.Notify(GetString("Chainbinder.NotEnoughTargets"));
+ binder.RpcResetAbilityCooldown();
+ return;
+ }
+
+ FirstTarget = first.PlayerId;
+ SecondTarget = second.PlayerId;
+ RemainingDuration = LinkDuration.GetFloat();
+ PullTimer = 0f;
+
+ SyncState();
+
+ binder.Notify(string.Format(GetString("Chainbinder.PairBound"), FirstTarget.ColoredPlayerName(), SecondTarget.ColoredPlayerName()));
+ Utils.NotifyRoles(SpecifySeer: binder, SpecifyTarget: binder);
+
+ Logger.Info($"{binder.GetNameWithRole().RemoveHtmlTags()} chained {first.GetNameWithRole().RemoveHtmlTags()} to {second.GetNameWithRole().RemoveHtmlTags()}", "Chainbinder");
+ }
+
+ private bool TryGetTargets(PlayerControl binder, PlayerControl requestedTarget, out PlayerControl first, out PlayerControl second)
+ {
+ first = null;
+ second = null;
+
+ List validTargets = Main.AllAlivePlayerControls
+ .Where(x => IsValidTarget(binder, x))
+ .ToList();
+
+ if (requestedTarget != null)
+ {
+ if (!IsValidTarget(binder, requestedTarget)) return false;
+
+ first = requestedTarget;
+ byte firstId = first.PlayerId;
+ Vector2 firstPosition = first.Pos();
+ second = validTargets
+ .Where(x => x.PlayerId != firstId)
+ .OrderBy(x => Vector2.Distance(x.Pos(), firstPosition))
+ .FirstOrDefault();
+
+ return second != null;
+ }
+
+ PlayerControl[] closestTargets = validTargets
+ .OrderBy(x => Vector2.Distance(x.Pos(), binder.Pos()))
+ .Take(2)
+ .ToArray();
+
+ if (closestTargets.Length < 2) return false;
+
+ first = closestTargets[0];
+ second = closestTargets[1];
+ return true;
+ }
+
+ private bool IsValidTarget(PlayerControl binder, PlayerControl target)
+ {
+ if (binder == null || target == null || target.PlayerId == binder.PlayerId || !target.IsAlive()) return false;
+ if (Pelican.IsEaten(target.PlayerId) || target.onLadder || target.inMovingPlat || target.inVent) return false;
+ if (!FastVector2.DistanceWithinRange(binder.Pos(), target.Pos(), BindRange.GetFloat())) return false;
+ if (!CanBindImpostors.GetBool() && target.GetCustomRole().IsImpostor()) return false;
+ return !IsLinkedByAnotherChainbinder(target.PlayerId);
+ }
+
+ private bool IsLinkedByAnotherChainbinder(byte targetId)
+ {
+ foreach (byte playerId in PlayerIdList)
+ {
+ if (playerId == ChainbinderId || !Main.PlayerStates.TryGetValue(playerId, out PlayerState state) || state.Role is not Chainbinder cb || !cb.HasLink) continue;
+ if (cb.FirstTarget == targetId || cb.SecondTarget == targetId) return true;
+ }
+
+ return false;
+ }
+
+ private static bool AreLinkedPlayersValid(PlayerControl first, PlayerControl second)
+ {
+ return first != null && second != null && first.IsAlive() && second.IsAlive() && !Pelican.IsEaten(first.PlayerId) && !Pelican.IsEaten(second.PlayerId);
+ }
+
+ private void ClearLink(bool notifyBinder = false, bool sync = true, bool refresh = true)
+ {
+ bool hadLink = HasLink;
+ ClearLocalState();
+
+ if (sync) SyncState();
+ if (!hadLink) return;
+
+ PlayerControl binder = Utils.GetPlayerById(ChainbinderId);
+ if (notifyBinder && binder != null && binder.IsAlive())
+ binder.Notify(GetString("Chainbinder.PairEnded"));
+
+ if (refresh && binder != null)
+ Utils.NotifyRoles(SpecifySeer: binder, SpecifyTarget: binder);
+ }
+
+ private void ClearLocalState()
+ {
+ FirstTarget = byte.MaxValue;
+ SecondTarget = byte.MaxValue;
+ RemainingDuration = 0f;
+ PullTimer = 0f;
+ }
+
+ private void SyncState()
+ {
+ if (!Utils.DoRPC) return;
+ Utils.SendRPC(CustomRPC.SyncRoleData, ChainbinderId, FirstTarget, SecondTarget);
+ }
+}
diff --git a/Roles/Standard/Neutral/Killing/Jackpot.cs b/Roles/Standard/Neutral/Killing/Jackpot.cs
new file mode 100644
index 000000000..bc69d51a1
--- /dev/null
+++ b/Roles/Standard/Neutral/Killing/Jackpot.cs
@@ -0,0 +1,314 @@
+using System.Collections.Generic;
+using AmongUs.GameOptions;
+using EHR.Modules;
+using UnityEngine;
+
+namespace EHR.Roles;
+
+using static EHR.Translator;
+
+public class Jackpot : RoleBase
+{
+ private const int Id = 658620;
+
+ public static bool On;
+ private static List PlayerIdList = [];
+
+ private static OptionItem KillCooldown;
+ private static OptionItem StartingMoney;
+ private static OptionItem MoneyPerKill;
+ private static OptionItem MissCost;
+ private static OptionItem SpinInterval;
+ private static OptionItem BaseJackpotChance;
+ private static OptionItem MissChanceIncrease;
+ private static OptionItem NearMissChance;
+ private static OptionItem NearMissChanceIncrease;
+ private static OptionItem NearMissDuration;
+ private static OptionItem NearMissSpeed;
+ private static OptionItem NearMissVision;
+ private static OptionItem JackpotDuration;
+ private static OptionItem JackpotSpeed;
+ private static OptionItem JackpotVision;
+
+ private byte JackpotId;
+ private int Money;
+ private int CurrentJackpotChanceValue;
+ private float SpinTimer;
+ private float NearMissTimer;
+ private float JackpotTimer;
+ private bool DomainActive;
+ private bool LastNearMissState;
+ private bool LastJackpotState;
+
+ public override bool IsEnable => On;
+
+ private bool IsNearMissActive => NearMissTimer > 0f;
+ private bool IsJackpotActive => JackpotTimer > 0f;
+
+ public override void SetupCustomOption()
+ {
+ StartSetup(Id)
+ .AutoSetupOption(ref KillCooldown, 22.5f, new FloatValueRule(0f, 180f, 0.5f), OptionFormat.Seconds)
+ .AutoSetupOption(ref StartingMoney, 4, new IntegerValueRule(1, 30, 1), overrideName: "Jackpot.StartingMoney")
+ .AutoSetupOption(ref MoneyPerKill, 2, new IntegerValueRule(0, 10, 1), overrideName: "Jackpot.MoneyPerKill")
+ .AutoSetupOption(ref MissCost, 1, new IntegerValueRule(1, 10, 1), overrideName: "Jackpot.MissCost")
+ .AutoSetupOption(ref SpinInterval, 5f, new FloatValueRule(1f, 30f, 0.5f), OptionFormat.Seconds, overrideName: "Jackpot.SpinInterval")
+ .AutoSetupOption(ref BaseJackpotChance, 5, new IntegerValueRule(1, 100, 1), OptionFormat.Percent, overrideName: "Jackpot.BaseChance")
+ .AutoSetupOption(ref MissChanceIncrease, 2, new IntegerValueRule(1, 25, 1), OptionFormat.Percent, overrideName: "Jackpot.MissChanceIncrease")
+ .AutoSetupOption(ref NearMissChance, 15, new IntegerValueRule(0, 100, 1), OptionFormat.Percent, overrideName: "Jackpot.NearChance")
+ .AutoSetupOption(ref NearMissChanceIncrease, 5, new IntegerValueRule(1, 50, 1), OptionFormat.Percent, overrideName: "Jackpot.NearChanceIncrease")
+ .AutoSetupOption(ref NearMissDuration, 4f, new FloatValueRule(0.5f, 20f, 0.5f), OptionFormat.Seconds, overrideName: "Jackpot.NearDuration")
+ .AutoSetupOption(ref NearMissSpeed, 1.35f, new FloatValueRule(0.25f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.NearSpeed")
+ .AutoSetupOption(ref NearMissVision, 1.25f, new FloatValueRule(0.1f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.NearVision")
+ .AutoSetupOption(ref JackpotDuration, 15f, new FloatValueRule(10f, 20f, 0.5f), OptionFormat.Seconds, overrideName: "Jackpot.Duration")
+ .AutoSetupOption(ref JackpotSpeed, 1.8f, new FloatValueRule(0.25f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.Speed")
+ .AutoSetupOption(ref JackpotVision, 2.5f, new FloatValueRule(0.1f, 5f, 0.05f), OptionFormat.Multiplier, overrideName: "Jackpot.Vision");
+ }
+
+ public override void Init()
+ {
+ On = false;
+ PlayerIdList = [];
+ }
+
+ public override void Add(byte playerId)
+ {
+ On = true;
+ PlayerIdList.Add(playerId);
+ JackpotId = playerId;
+ Money = StartingMoney.GetInt();
+ CurrentJackpotChanceValue = BaseJackpotChance.GetInt();
+ SpinTimer = 0f;
+ NearMissTimer = 0f;
+ JackpotTimer = 0f;
+ DomainActive = false;
+ LastNearMissState = false;
+ LastJackpotState = false;
+ }
+
+ public override void Remove(byte playerId)
+ {
+ PlayerIdList.Remove(playerId);
+ On = PlayerIdList.Count > 0;
+ }
+
+ public override void SetKillCooldown(byte id)
+ {
+ Main.AllPlayerKillCooldown[id] = IsJackpotActive ? 0.1f : KillCooldown.GetFloat();
+ }
+
+ public override bool CanUseImpostorVentButton(PlayerControl pc)
+ {
+ return false;
+ }
+
+ public override bool CanUseSabotage(PlayerControl pc)
+ {
+ return false;
+ }
+
+ public override void ApplyGameOptions(IGameOptions opt, byte id)
+ {
+ AURoleOptions.ShapeshifterCooldown = 1f;
+ AURoleOptions.ShapeshifterDuration = 1f;
+
+ if (!IsNearMissActive && !IsJackpotActive) return;
+
+ opt.SetVision(false);
+ float vision = IsJackpotActive ? JackpotVision.GetFloat() : NearMissVision.GetFloat();
+ opt.SetFloat(FloatOptionNames.CrewLightMod, vision);
+ opt.SetFloat(FloatOptionNames.ImpostorLightMod, vision);
+ }
+
+ public override void SetButtonTexts(HudManager hud, byte id)
+ {
+ hud.AbilityButton?.OverrideText(GetString("JackpotButtonText"));
+ }
+
+ public override bool OnShapeshift(PlayerControl shapeshifter, PlayerControl target, bool shapeshifting)
+ {
+ if (!shapeshifting) return false;
+
+ StartDomain(shapeshifter);
+ return false;
+ }
+
+ public override void OnMurder(PlayerControl killer, PlayerControl target)
+ {
+ if (killer == null || killer.PlayerId != JackpotId || !target) return;
+
+ Money += MoneyPerKill.GetInt();
+ killer.Notify(string.Format(GetString("Jackpot.KillReward"), MoneyPerKill.GetInt(), Money));
+ }
+
+ public override bool OnCheckMurderAsTarget(PlayerControl killer, PlayerControl target)
+ {
+ return !IsJackpotActive;
+ }
+
+ public override void OnFixedUpdate(PlayerControl pc)
+ {
+ if (pc == null || pc.PlayerId != JackpotId) return;
+
+ if (!GameStates.IsInTask || !pc.IsAlive())
+ {
+ if (DomainActive || IsNearMissActive || IsJackpotActive)
+ StopSequence(pc, resetChance: true, notifyEnd: false, refreshCooldown: false);
+
+ return;
+ }
+
+ if (IsNearMissActive)
+ NearMissTimer = Mathf.Max(0f, NearMissTimer - Time.fixedDeltaTime);
+
+ if (IsJackpotActive)
+ JackpotTimer = Mathf.Max(0f, JackpotTimer - Time.fixedDeltaTime);
+
+ if (LastJackpotState && !IsJackpotActive)
+ {
+ StopSequence(pc, resetChance: true, notifyEnd: true, refreshCooldown: true);
+ return;
+ }
+
+ if (DomainActive && !IsJackpotActive)
+ {
+ SpinTimer -= Time.fixedDeltaTime;
+
+ while (SpinTimer <= 0f && DomainActive && !IsJackpotActive)
+ {
+ SpinTimer += SpinInterval.GetFloat();
+ ResolveSpin(pc);
+ }
+ }
+
+ RefreshTemporaryBuffs(pc, refreshCooldown: false);
+ }
+
+ public override void OnReportDeadBody()
+ {
+ PlayerControl jackpot = JackpotId.GetPlayer();
+ StopSequence(jackpot, resetChance: true, notifyEnd: false, refreshCooldown: false);
+ }
+
+ public override void AfterMeetingTasks()
+ {
+ PlayerControl jackpot = JackpotId.GetPlayer();
+ StopSequence(jackpot, resetChance: true, notifyEnd: false, refreshCooldown: true);
+ }
+
+ public override string GetProgressText(byte playerId, bool comms)
+ {
+ if (playerId != JackpotId) return string.Empty;
+
+ string text = IsJackpotActive
+ ? string.Format(GetString("Jackpot.Progress.Jackpot"), Money, Mathf.CeilToInt(JackpotTimer))
+ : DomainActive
+ ? string.Format(GetString(IsNearMissActive ? "Jackpot.Progress.Near" : "Jackpot.Progress.Spin"), Money, CurrentJackpotChanceValue)
+ : string.Format(GetString("Jackpot.Progress.Ready"), Money, CurrentJackpotChanceValue);
+
+ return Utils.ColorString(Utils.GetRoleColor(CustomRoles.Jackpot), $" {text}");
+ }
+
+ private void StartDomain(PlayerControl player)
+ {
+ if (player == null || player.PlayerId != JackpotId || !player.IsAlive()) return;
+
+ if (DomainActive || IsJackpotActive)
+ {
+ player.Notify(GetString("Jackpot.DomainAlreadyActive"));
+ player.RpcResetAbilityCooldown();
+ return;
+ }
+
+ DomainActive = true;
+ SpinTimer = SpinInterval.GetFloat();
+ player.Notify($"{GetString("Jackpot.GameRulesTransmitted")}\n{string.Format(GetString("Jackpot.DomainStarted"), SpinInterval.GetFloat())}", 6f);
+ RefreshTemporaryBuffs(player, refreshCooldown: false);
+ }
+
+ // Domain phase: rolls keep happening until the player jackpots, dies, or a meeting interrupts the sequence.
+ private void ResolveSpin(PlayerControl player)
+ {
+ if (player == null || !player.IsAlive()) return;
+
+ int roll = IRandom.Instance.Next(100);
+
+ if (roll < CurrentJackpotChanceValue)
+ {
+ DomainActive = false;
+ NearMissTimer = 0f;
+ JackpotTimer = JackpotDuration.GetFloat();
+ CurrentJackpotChanceValue = BaseJackpotChance.GetInt();
+
+ player.Notify($"{GetString("Jackpot.Spin.Jackpot")}\n{string.Format(GetString("Jackpot.JackpotState"), JackpotDuration.GetFloat())}", JackpotDuration.GetFloat());
+ RefreshTemporaryBuffs(player, refreshCooldown: true);
+ return;
+ }
+
+ if (roll < CurrentJackpotChanceValue + NearMissChance.GetInt())
+ {
+ NearMissTimer = NearMissDuration.GetFloat();
+ CurrentJackpotChanceValue = Mathf.Clamp(CurrentJackpotChanceValue + NearMissChanceIncrease.GetInt(), 0, 100);
+ player.Notify(string.Format(GetString("Jackpot.Spin.Near"), NearMissDuration.GetFloat(), Money, CurrentJackpotChanceValue), NearMissDuration.GetFloat());
+ RefreshTemporaryBuffs(player, refreshCooldown: false);
+ return;
+ }
+
+ Money -= MissCost.GetInt();
+ CurrentJackpotChanceValue = Mathf.Clamp(CurrentJackpotChanceValue + MissChanceIncrease.GetInt(), 0, 100);
+
+ if (Money <= 0)
+ {
+ Money = 0;
+ StopSequence(player, resetChance: true, notifyEnd: false, refreshCooldown: false);
+ player.Notify(GetString("Jackpot.OutOfMoney"));
+ player.Suicide(PlayerState.DeathReason.Bankrupt);
+ return;
+ }
+
+ player.Notify(string.Format(GetString("Jackpot.Spin.Miss"), MissCost.GetInt(), Money, CurrentJackpotChanceValue));
+ }
+
+ // Near Miss and Jackpot both use the same buff refresh path so we only touch speed/settings when the phase changes.
+ private void RefreshTemporaryBuffs(PlayerControl player, bool refreshCooldown)
+ {
+ bool nearMissActive = IsNearMissActive;
+ bool jackpotActive = IsJackpotActive;
+
+ if (LastNearMissState == nearMissActive && LastJackpotState == jackpotActive && !refreshCooldown) return;
+
+ LastNearMissState = nearMissActive;
+ LastJackpotState = jackpotActive;
+
+ float speed = Main.RealOptionsData.GetFloat(FloatOptionNames.PlayerSpeedMod);
+
+ if (jackpotActive) speed = JackpotSpeed.GetFloat();
+ else if (nearMissActive) speed = NearMissSpeed.GetFloat();
+
+ Main.AllPlayerSpeed[JackpotId] = speed;
+
+ if (player == null || !player.IsAlive()) return;
+
+ if (refreshCooldown) player.ResetKillCooldown();
+ else player.MarkDirtySettings();
+ }
+
+ // Jackpot is the payoff window: once it ends, the chance resets and the button is ready again immediately.
+ private void StopSequence(PlayerControl player, bool resetChance, bool notifyEnd, bool refreshCooldown)
+ {
+ bool wasJackpotActive = IsJackpotActive;
+
+ DomainActive = false;
+ SpinTimer = 0f;
+ NearMissTimer = 0f;
+ JackpotTimer = 0f;
+
+ if (resetChance)
+ CurrentJackpotChanceValue = BaseJackpotChance.GetInt();
+
+ RefreshTemporaryBuffs(player, refreshCooldown);
+
+ if (notifyEnd && wasJackpotActive && player != null && player.IsAlive())
+ player.Notify(GetString("Jackpot.JackpotEnded"));
+ }
+}