diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json
index fe1ea886..8027737f 100644
--- a/BaseLib/localization/eng/static_hover_tips.json
+++ b/BaseLib/localization/eng/static_hover_tips.json
@@ -11,4 +11,8 @@
"BASELIB-SCRY.title": "Scry",
"BASELIB-SCRY.description": "Look at the top [blue]X[/blue] cards of your [gold]Draw Pile[/gold]. You may discard any of them.",
"BASELIB-SCRY.smartDescription": "Look at the top {Scry:plural:card|[blue]{Scry:diff()}[/blue] cards} of your [gold]Draw Pile[/gold]. You may discard {Scry:plural:it|any of them}."
+ "BASELIB-BUNDLED_REWARDS.title": "Bundled Rewards",
+ "BASELIB-BUNDLED_REWARDS.description": "You can only select [gold]All[/gold] or [gold]No[/gold] rewards from this set.",
+ "BASELIB-EXCLUSIVE_REWARDS.title": "Exclusive Rewards",
+ "BASELIB-EXCLUSIVE_REWARDS.description": "You can only select [blue]1[/blue] reward from this set."
}
\ No newline at end of file
diff --git a/BaseLib/localization/jpn/static_hover_tips.json b/BaseLib/localization/jpn/static_hover_tips.json
index 3868dba9..8de26fe6 100644
--- a/BaseLib/localization/jpn/static_hover_tips.json
+++ b/BaseLib/localization/jpn/static_hover_tips.json
@@ -11,4 +11,4 @@
"BASELIB-SCRY.title": "占術",
"BASELIB-SCRY.description": "山札の上からカードを[blue]X[/blue]枚見て、その中から好きなだけ捨ててもよい。",
"BASELIB-SCRY.smartDescription": "山札の上から{Scry:plural:カードを1枚|カードを[blue]{Scry:diff()}[/blue]枚}見て、{Scry:plural:それを捨ててもよい|その中から好きなだけ捨ててもよい}。"
-}
\ No newline at end of file
+}
diff --git a/BaseLib/scenes/linked_reward_set.tscn b/BaseLib/scenes/linked_reward_set.tscn
new file mode 100644
index 00000000..89099b52
--- /dev/null
+++ b/BaseLib/scenes/linked_reward_set.tscn
@@ -0,0 +1,45 @@
+[gd_scene load_steps=2 format=3 uid="uid://bg6wy5uy5jriq"]
+
+[ext_resource type="Script" uid="uid://7xryq86mhqfn" path="res://BaseLibScenes/NCustomLinkedRewardSet.cs" id="1_2c5wt"]
+
+[node name="LinkedRewardSet" type="MarginContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_2c5wt")
+
+[node name="ColorRect" type="ColorRect" parent="."]
+layout_mode = 2
+color = Color(0.078431375, 0.12941177, 0.15294118, 1)
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+layout_mode = 2
+theme_override_constants/margin_left = 10
+theme_override_constants/margin_top = 15
+theme_override_constants/margin_right = 10
+theme_override_constants/margin_bottom = 15
+
+[node name="RewardContainer" type="VBoxContainer" parent="MarginContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="Control" type="Control" parent="."]
+layout_mode = 2
+mouse_filter = 2
+
+[node name="ChainContainer" type="Control" parent="Control"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 13
+anchor_left = 0.5
+anchor_right = 0.5
+anchor_bottom = 1.0
+offset_left = -66.0
+offset_top = 48.0
+offset_right = 34.0
+offset_bottom = 11.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs
new file mode 100644
index 00000000..d9bd480b
--- /dev/null
+++ b/BaseLibScenes/NCustomLinkedRewardSet.cs
@@ -0,0 +1,253 @@
+using System.Reflection;
+using System.Reflection.Emit;
+using BaseLib.Common.Rewards.LinkedRewardSet;
+using BaseLib.Utils;
+using Godot;
+using HarmonyLib;
+using MegaCrit.Sts2.Core.Assets;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.Nodes.Rewards;
+using MegaCrit.Sts2.Core.Nodes.Screens;
+using MegaCrit.Sts2.Core.Rewards;
+
+namespace BaseLib.BaseLibScenes;
+
+// Our own linked reward node. The scene is identical to the base games linked rewards scene.
+// But having our own ensures base game changes to linked rewards will not cause issues.
+[GlobalClass]
+public partial class NCustomLinkedRewardSet : Control
+{
+ public static readonly StringName RewardClaimedSignalName = "RewardClaimed";
+
+ [Signal]
+ public delegate void RewardClaimedEventHandler(NCustomLinkedRewardSet customLinkedRewardSet);
+
+ ///
+ /// Similar case to only this class uses this. But technically anyone can now make use of the highlighting.
+ /// Including other modders.
+ /// Every NRewardButton gets the highlight node added, not just those used by CustomLinkedRewards.
+ ///
+ public static AddedNode NHighlights = new((rewardButton) =>
+ {
+ var textureRec = new NRewardHighlight();
+ // I am using the card glow texture and morph it into something that fits.
+ // If need be, we can add our own glow-reward-button hdr texture.
+ textureRec.Texture = PreloadManager.Cache.GetCompressedTexture2D("res://images/packed/card_template/card_frame_sdf.exr");
+ rewardButton.AddChildSafely(textureRec);
+ rewardButton.MoveChild(textureRec, rewardButton.GetNode("%Background").GetIndex());
+ textureRec.Modulate = new Color("00f4fcfa");
+ textureRec.MouseFilter = MouseFilterEnum.Ignore;
+ textureRec.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
+ textureRec.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered;
+ textureRec.Scale = new Vector2(1.8f, 0.3f);
+ textureRec.Position = new Vector2(-270f, -33f);
+ return textureRec;
+ });
+
+
+ // We are using our own scene because we don't know how/when MegaCrit modifies theirs
+ private static string ScenePath => $"res://BaseLib/scenes/linked_reward_set.tscn";
+ private static string ChainImagePath => ImageHelper.GetImagePath("/ui/reward_screen/reward_chain.png");
+
+ // The game groups some assets in AssetSets which exists to not clutter PreloadManager. We aren't using it yet.
+ // public static IEnumerable AssetPaths => [ScenePath, ChainImagePath];
+
+ private NRewardsScreen _rewardsScreen;
+ private Control _rewardContainer;
+ private Control _chainsContainer;
+ private bool _signalAlreadyReceived = false;
+ private readonly List _rewardButtons = [];
+
+ public CustomLinkedRewardSet CustomLinkedRewardSet { get; private set; }
+
+
+ public static NCustomLinkedRewardSet Create(CustomLinkedRewardSet linkedReward, NRewardsScreen screen)
+ {
+ var nCustomLinkedRewardSet = PreloadManager.Cache.GetScene(ScenePath).Instantiate(PackedScene.GenEditState.Disabled);
+ nCustomLinkedRewardSet._rewardsScreen = screen;
+ nCustomLinkedRewardSet.SetReward(linkedReward);
+ return nCustomLinkedRewardSet;
+ }
+
+ public override void _Ready()
+ {
+ _rewardContainer = GetNode("%RewardContainer");
+ _chainsContainer = GetNode("%ChainContainer");
+ NRewardButtonEvents.Focused += OnFocused;
+ NRewardButtonEvents.Unfocused += OnUnfocused;
+ Reload();
+ }
+
+ public override void _ExitTree()
+ {
+ NRewardButtonEvents.Focused -= OnFocused;
+ NRewardButtonEvents.Unfocused -= OnUnfocused;
+ }
+
+
+ private void SetReward(CustomLinkedRewardSet linkedReward)
+ {
+ CustomLinkedRewardSet = linkedReward;
+ if (IsNodeReady())
+ {
+ Reload();
+ }
+ }
+
+ private void Reload()
+ {
+ if (!IsNodeReady())
+ {
+ return;
+ }
+ _rewardButtons.Clear();
+ for (var i = 0; i < CustomLinkedRewardSet.Rewards.Count; i++)
+ {
+ var reward = CustomLinkedRewardSet.Rewards[i];
+ var nRewardButton = NRewardButton.Create(reward, _rewardsScreen);
+ _rewardButtons.Add(nRewardButton);
+ nRewardButton.CustomMinimumSize -= Vector2.Right * 20f;
+ _rewardContainer.AddChildSafely(nRewardButton);
+ nRewardButton.Connect(NRewardButton.SignalName.RewardClaimed, Callable.From(GetReward));
+ if (i >= CustomLinkedRewardSet.Rewards.Count - 1) continue;
+ var textureRect = new TextureRect();
+ textureRect.MouseFilter = MouseFilterEnum.Ignore;
+ textureRect.Texture = PreloadManager.Cache.GetCompressedTexture2D(ChainImagePath);
+ textureRect.Size = Vector2.One * 50f;
+ _chainsContainer.AddChildSafely(textureRect);
+ textureRect.GlobalPosition = _chainsContainer.GlobalPosition + Vector2.Down * (-8 + i * (5f + nRewardButton.CustomMinimumSize.Y));
+ }
+ }
+
+ private void OnFocused(NRewardButton button)
+ {
+ if (!_rewardButtons.Contains(button)) return;
+ switch (CustomLinkedRewardSet.LinkedRewardType)
+ {
+ case LinkedRewardType.Exclusive:
+ foreach (var rewardButton in _rewardButtons)
+ {
+ var rewardHighlight = NHighlights.Get(rewardButton);
+ rewardHighlight.AnimShow();
+ rewardHighlight.Modulate = rewardButton == button ? NRewardHighlight.gold : NRewardHighlight.red;
+ }
+ break;
+ case LinkedRewardType.Bundled:
+ foreach (var rewardHighlight in _rewardButtons.Select(rewardButton => NHighlights.Get(rewardButton)))
+ {
+ rewardHighlight.AnimShow();
+ rewardHighlight.Modulate = NRewardHighlight.gold;
+ }
+
+ break;
+ default:
+ break;
+ }
+
+ }
+
+ private void OnUnfocused(NRewardButton button)
+ {
+ if (!_rewardButtons.Contains(button)) return;
+ foreach (var rewardButton in _rewardButtons)
+ NHighlights.Get(rewardButton)?.AnimHide();
+ }
+
+ private void GetReward(NRewardButton button)
+ {
+ if (CustomLinkedRewardSet.LinkedRewardType == LinkedRewardType.Bundled)
+ {
+ if (_signalAlreadyReceived) return;
+ _signalAlreadyReceived = true;
+ _rewardButtons.Remove(button);
+ foreach (var rewardButton in _rewardButtons)
+ {
+ rewardButton.GetReward();
+ }
+ }
+ EmitSignal(RewardClaimedSignalName, this);
+ this.QueueFreeSafely();
+ }
+}
+
+///
+/// Currently only subscribes to these. But technically anyone could subscribe to them.
+/// If it turns out others will use these, we could also add the "OnPress" and "OnRelease".
+/// Note that these are invoked after the original method fully ran.
+///
+[HarmonyPatch]
+public static class NRewardButtonEvents
+{
+ ///
+ /// Invoked whenever a NRewardButton gets focused by the player
+ ///
+ public static event Action Focused;
+ ///
+ /// Invoked whenever a NRewardButton gets unfocused by the player
+ ///
+ public static event Action Unfocused;
+
+ [HarmonyPatch(typeof(NRewardButton), "OnFocus")]
+ [HarmonyPostfix]
+ private static void OnFocusEvent(NRewardButton __instance)
+ {
+ Focused?.Invoke(__instance);
+ }
+ [HarmonyPatch(typeof(NRewardButton), "OnUnfocus")]
+ [HarmonyPostfix]
+ private static void OnUnfocusEvent(NRewardButton __instance)
+ {
+ Unfocused?.Invoke(__instance);
+ }
+}
+
+
+[HarmonyPatch]
+public static class NCustomLinkedRewardSetPatches
+{
+
+ // roughly at line 190 - 205 in the decompiled code
+ // Add support for NCustomLinkedRewardSet
+ [HarmonyTranspiler]
+ [HarmonyPatch(typeof(NRewardsScreen), "_Ready")]
+ private static IEnumerable ReplaceConnectMethodWithCustom(IEnumerable instructions, MethodBase originalMethod)
+ {
+ var customLinkedRewardSetCheck = AccessTools.Method(typeof(NCustomLinkedRewardSetPatches), nameof(CustomLinkedRewardSetCheck));
+ var nRewardButtonCreate = AccessTools.Method(typeof(NRewardButton), nameof(NRewardButton.Create));
+
+ var matcher = new CodeMatcher(instructions)
+ .MatchStartForward(new CodeMatch(OpCodes.Call, nRewardButtonCreate))
+ .ThrowIfInvalid("Could not find NRewardButton.Create call (else-branch start)");
+ matcher.Advance(-3); // Step back to the else-branch's real first instruction
+ var optionField = (FieldInfo)matcher.InstructionAt(4).operand;
+ var endLabel = matcher.InstructionAt(-1).operand;
+
+ // This instruction is the actual jump target for the `if` check (item is LinkedRewardSet == false).
+ // Detach its label so we can move it onto our own first instruction instead.
+ var elseEntryLabels = new List(matcher.Labels);
+ matcher.Labels.Clear();
+
+ var loadDisplayClass = matcher.Instruction.Clone().WithLabels(elseEntryLabels); // ldloc.s (now the real entry point)
+ var loadRewardItem = matcher.InstructionAt(1).Clone(); // ldloc.s reward
+
+ matcher.Insert([
+ loadDisplayClass,
+ new CodeInstruction(OpCodes.Ldflda, optionField),
+ loadRewardItem,
+ new CodeInstruction(OpCodes.Ldarg_0), // this
+ new CodeInstruction(OpCodes.Call, customLinkedRewardSetCheck),
+ new CodeInstruction(OpCodes.Brtrue, endLabel), // reward handled -> skip the rest of the else-branch
+ ]); // insert before the (now unlabeled) original else-branch code
+
+ return matcher.InstructionEnumeration();
+ }
+
+
+ private static bool CustomLinkedRewardSetCheck(ref Control option, Reward item, NRewardsScreen screen)
+ {
+ if (item is not CustomLinkedRewardSet clrs) return false;
+ option = NCustomLinkedRewardSet.Create(clrs, screen);
+ option.Connect(NCustomLinkedRewardSet.RewardClaimedSignalName, Callable.From(screen.RewardCollectedFrom));
+ return true;
+ }
+}
diff --git a/BaseLibScenes/NRewardHighlight.cs b/BaseLibScenes/NRewardHighlight.cs
new file mode 100644
index 00000000..e749d6b7
--- /dev/null
+++ b/BaseLibScenes/NRewardHighlight.cs
@@ -0,0 +1,73 @@
+using Godot;
+
+namespace BaseLib.BaseLibScenes;
+
+///
+/// Allows highlighting of rewards in the reward screen. Similar to card highlights.
+///
+[GlobalClass]
+public partial class NRewardHighlight : TextureRect
+{
+ private static readonly StringName _shaderParameterWidth = new StringName("width");
+ private static readonly StringName _shaderParameterEase = new StringName("ease");
+ private static readonly StringName _shaderParameterModuloWidth = new StringName("modulo_width");
+ private static readonly StringName _shaderParameterRippleSpeed = new StringName("ripple_speed");
+
+
+
+ public static readonly Color gold = new Color(1f, 0.784f, 0f, 0.98f);
+ public static readonly Color red = new Color(0.93f, 0f, 0.13f, 0.98f);
+
+ private Tween? _curTween;
+
+ private ShaderMaterial _shaderMaterial;
+
+ public override void _Ready()
+ {
+ _shaderMaterial = new ShaderMaterial();
+ _shaderMaterial.Shader = GD.Load("res://shaders/card_ripple.gdshader");
+ _shaderMaterial.SetShaderParameter(_shaderParameterEase, 0.005f);
+ _shaderMaterial.SetShaderParameter(_shaderParameterModuloWidth, 0.02f);
+ _shaderMaterial.SetShaderParameter(_shaderParameterRippleSpeed, 0.01f);
+ _shaderMaterial.SetShaderParameter(_shaderParameterWidth, 0.0f);
+ Material = _shaderMaterial;
+ }
+
+ public void AnimShow()
+ {
+ _curTween?.Kill();
+ _curTween = CreateTween();
+ _curTween.TweenMethod(Callable.From(SetShaderParameter), GetShaderParameter(), 0.075f, 0.5).SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
+ }
+
+ public void AnimHide()
+ {
+ _curTween?.Kill();
+ _curTween = CreateTween();
+ _curTween.TweenMethod(Callable.From(SetShaderParameter), GetShaderParameter(), 0.0, 0.5);
+ }
+
+ public void AnimHideInstantly()
+ {
+ _curTween?.Kill();
+ SetShaderParameter(0f);
+ }
+
+ public void AnimFlash()
+ {
+ _curTween?.Kill();
+ _curTween = CreateTween();
+ _curTween.TweenMethod(Callable.From(SetShaderParameter), GetShaderParameter(), 0.15f, 0.1);
+ _curTween.TweenMethod(Callable.From(SetShaderParameter), 0.15f, 0.075f, 0.35).SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
+ }
+
+ private float GetShaderParameter()
+ {
+ return _shaderMaterial.GetShaderParameter(_shaderParameterWidth).AsSingle();
+ }
+
+ private void SetShaderParameter(float val)
+ {
+ _shaderMaterial.SetShaderParameter(_shaderParameterWidth, val);
+ }
+}
diff --git a/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs
new file mode 100644
index 00000000..abdfa688
--- /dev/null
+++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs
@@ -0,0 +1,51 @@
+using BaseLib.Abstracts;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.Multiplayer.Serialization;
+using MegaCrit.Sts2.Core.Runs;
+
+namespace BaseLib.Common.Rewards.LinkedRewardSet;
+
+public class CustomLinkedRewardChoiceMessage : ICustomMessage
+{
+ public int setId;
+ public int containerIndex;
+ public int nestedIndex;
+
+ public bool ShouldBroadcast => true;
+
+ public void Serialize(PacketWriter writer)
+ {
+ writer.WriteInt(setId);
+ writer.WriteInt(containerIndex, 8);
+ writer.WriteInt(nestedIndex, 8);
+ }
+
+ public void Deserialize(PacketReader reader)
+ {
+ setId = reader.ReadInt();
+ containerIndex = reader.ReadInt(8);
+ nestedIndex = reader.ReadInt(8);
+ }
+
+ public void HandleMessage(ulong senderId)
+ {
+ if (RunManager.Instance.State is null)
+ {
+ BaseLibMain.Logger.Error("RunManager.State is null in a context where it shouldn't be");
+ return;
+ }
+ var synchronizer = RunManager.Instance.RewardsSetSynchronizer;
+ var player = RunManager.Instance.State.GetPlayer(senderId);
+ if (player is null) return;
+ var rewardStateForPlayer = synchronizer.GetRewardStateForPlayer(player);
+ var rewardsSetState = rewardStateForPlayer.rewardsStack.Last(); // same assumption the base game itself makes
+ var set = rewardsSetState.set;
+
+ if (containerIndex < 0 || containerIndex >= set.Rewards.Count) return;
+ if (set.Rewards[containerIndex] is not CustomLinkedRewardSet container) return;
+ if (nestedIndex < 0 || nestedIndex >= container.Rewards.Count) return;
+
+ container.SetPendingSelection(container.Rewards[nestedIndex]);
+ TaskHelper.RunSafely(synchronizer.SelectRewardForPlayer(rewardsSetState, container));
+ }
+}
\ No newline at end of file
diff --git a/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs
new file mode 100644
index 00000000..4e768938
--- /dev/null
+++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs
@@ -0,0 +1,293 @@
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using BaseLib.Abstracts;
+using BaseLib.BaseLibScenes;
+using BaseLib.Patches.Content;
+using BaseLib.Patches.Saves;
+using BaseLib.Utils;
+using HarmonyLib;
+using MegaCrit.Sts2.Core.Entities.Players;
+using MegaCrit.Sts2.Core.HoverTips;
+using MegaCrit.Sts2.Core.Localization;
+using MegaCrit.Sts2.Core.Multiplayer.Game;
+using MegaCrit.Sts2.Core.Multiplayer.Serialization;
+using MegaCrit.Sts2.Core.Nodes.Rewards;
+using MegaCrit.Sts2.Core.Rewards;
+using MegaCrit.Sts2.Core.Saves.Runs;
+
+namespace BaseLib.Common.Rewards.LinkedRewardSet;
+
+// We do not use BaseGame rewards because they are slightly buggy, and we are providing additional features which is too much work to patch in.
+// May get obsolete once base game fully implements their version of it
+///
+/// Custom RewardSet for linked rewards. Supports two ways of resolving:
+/// 1. May choose one or none (Default. Equal to Sts1 behaviour)
+/// 2. Must choose all or none
+/// Do not nest LinkedRewards
+///
+public class CustomLinkedRewardSet : CustomReward
+{
+ [CustomEnum] public static RewardType CustomLinkedRewardType;
+ private const string SaveId = "baselib_customlinkedrewardset_children";
+
+
+ private static readonly SpireField ParentCustomLinkedRewardSet = new(() => null);
+ public static bool TryGetCustomLinkedRewardSet(Reward reward, [NotNullWhen(true)] out CustomLinkedRewardSet? customLinkedRewardSet)
+ {
+ customLinkedRewardSet = ParentCustomLinkedRewardSet.Get(reward);
+ return customLinkedRewardSet != null;
+ }
+
+
+ private LocString HoverTipTitle => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled
+ ? "BASELIB-BUNDLED_REWARDS.title" : "BASELIB-EXCLUSIVE_REWARDS.title");
+ private LocString HoverTipDesc => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled
+ ? "BASELIB-BUNDLED_REWARDS.description" : "BASELIB-EXCLUSIVE_REWARDS.description");
+ public HoverTip HoverTip => new(HoverTipTitle, HoverTipDesc);
+ public override LocString Description => new("gameplay_ui", "COMBAT_REWARD_LINKED");
+
+
+ private List _rewards;
+ private LinkedRewardType _linkedRewardType;
+ private bool _selectionStarted = false;
+ private Reward? _pendingSelection;
+
+ public IReadOnlyList Rewards => _rewards.ToList();
+ protected override RewardType RewardType => CustomLinkedRewardType;
+ public LinkedRewardType LinkedRewardType => _linkedRewardType;
+ public override int RewardsSetIndex => Rewards.Max(r => r.RewardsSetIndex);
+ public override bool IsPopulated => _rewards.All(r => r.IsPopulated);
+
+
+
+ ///
+ /// Exists only for BaseLib Save logic registration.
+ /// Do not use.
+ ///
+ private CustomLinkedRewardSet() : base(null!)
+ {
+ _rewards = [];
+ }
+ public CustomLinkedRewardSet(List rewards, Player player, LinkedRewardType linkedRewardType = LinkedRewardType.Exclusive) : base(player)
+ {
+ _rewards = rewards;
+ _linkedRewardType = linkedRewardType;
+ foreach (var reward in _rewards)
+ ParentCustomLinkedRewardSet.Set(reward, this);
+ }
+
+ public override void Populate()
+ {
+ foreach (var reward in _rewards)
+ reward.Populate();
+ }
+
+ public override void MarkContentAsSeen()
+ {
+ foreach (var reward in _rewards)
+ reward.MarkContentAsSeen();
+ }
+
+ public override void OnSkipped()
+ {
+ foreach (var reward in _rewards)
+ reward.OnSkipped();
+ }
+
+ protected override async Task OnSelect()
+ {
+ if (_selectionStarted) return SuccessfullySelected;
+ _selectionStarted = true;
+ return LinkedRewardType switch
+ {
+ LinkedRewardType.Exclusive => await OnSelectExclusive(),
+ LinkedRewardType.Bundled => await OnSelectBundled(),
+ _ => true
+ };
+ }
+
+ private async Task OnSelectExclusive()
+ {
+ var chosen = _pendingSelection;
+ _pendingSelection = null;
+ if (chosen == null)
+ {
+ BaseLibMain.Logger.Error("No Reward selected. Should not happen!");
+ _selectionStarted = false;
+ return false;
+ }
+
+ if (!(chosen.SuccessfullySelected || await chosen.SelectUnsynchronized()))
+ {
+ _selectionStarted = false;
+ return false;
+ }
+ foreach (var reward in _rewards.ToList().Where(reward => reward != chosen && !reward.SuccessfullySelected))
+ reward.OnSkipped();
+ return true;
+ }
+ private async Task OnSelectBundled()
+ {
+ var ordered = _rewards.ToList();
+ var clicked = _pendingSelection;
+ _pendingSelection = null;
+ if (clicked != null && ordered.Remove(clicked)) // move the selected reward to the front, so it is offered first
+ ordered.Insert(0, clicked);
+ foreach (var reward in ordered)
+ {
+ if (reward.SuccessfullySelected) continue;
+ if (await reward.SelectUnsynchronized()) continue;
+ if (!_rewards.Any(r => r.SuccessfullySelected))
+ {
+ // Nothing claimed yet: the player was only peeking, back out and keep the set available.
+ _selectionStarted = false;
+ return false;
+ }
+
+ // The bundle is already committed, so a skip is a final "I don't want any of these" (for this reward only).
+ // The remaining rewards are still offered before the set resolves.
+ reward.OnSkipped();
+ }
+ return true;
+ }
+
+ ///
+ /// is not being notified of this.
+ /// Using it during the reward screen can lead to undefined behaviour.
+ ///
+ ///
+ public void RemoveReward(Reward reward) => _rewards.Remove(reward);
+
+
+ public override CreateRewardFromSave DeserializeMethod => CreateFromSerializable;
+
+ ///
+ /// Exclusive: the reward the player chose.
+ /// Bundled: the reward whose button was clicked (player should see that one first to not confuse them)
+ ///
+ public void SetPendingSelection(Reward chosen) => _pendingSelection = chosen;
+
+ public static CustomReward CreateFromSerializable(SerializableReward save, Player player)
+ => new CustomLinkedRewardSet([], player);
+
+ private void RestoreRewards(List rewards, LinkedRewardType linkedRewardType)
+ {
+ _rewards = rewards;
+ _linkedRewardType = linkedRewardType;
+ foreach (var reward in _rewards)
+ ParentCustomLinkedRewardSet.Set(reward, this);
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ ExtendedSaveTypes.RegisterObjectSaveType(
+ ExtendedSaveTypes.PropertyFunc>(
+ nameof(SerializableCustomLinkedRewardSet.Rewards)),
+ ExtendedSaveTypes.PropertyFunc(
+ nameof(SerializableCustomLinkedRewardSet.LinkedRewardTypeValue))
+ );
+ ExtendedSaveHandlers.RegisterSave(
+ SaveId,
+ reward => reward is CustomLinkedRewardSet clrs
+ ? new SerializableCustomLinkedRewardSet
+ {
+ Rewards = clrs.Rewards.Select(r => r.ToSerializable()).ToList(),
+ LinkedRewardTypeValue = (int)clrs.LinkedRewardType
+ }
+ : null,
+ (reward, value) =>
+ {
+ if (reward is not CustomLinkedRewardSet clrs || value == null) return;
+ var restored = value.Rewards.Select(sr => Reward.FromSerializable(sr, reward.Player)).ToList();
+ clrs.RestoreRewards(restored, (LinkedRewardType)value.LinkedRewardTypeValue );
+ });
+ }
+}
+
+public class SerializableCustomLinkedRewardSet : IPacketSerializable
+{
+ public List Rewards { get; set; } = [];
+ public int LinkedRewardTypeValue { get; set; }
+
+
+ public void Serialize(PacketWriter writer)
+ {
+ writer.WriteList(Rewards);
+ writer.WriteInt(LinkedRewardTypeValue);
+ }
+
+ public void Deserialize(PacketReader reader)
+ {
+ Rewards = reader.ReadList();
+ LinkedRewardTypeValue = reader.ReadInt();
+ }
+}
+
+[HarmonyPatch]
+public static class CustomLinkedRewardSet_Patches
+{
+ [HarmonyPatch(typeof(NRewardButton), "SetReward")]
+ [HarmonyPrefix]
+ private static void ThrowIfMisuseOfCustomRewardSet(Reward reward)
+ {
+ if(reward is CustomLinkedRewardSet)
+ throw new ArgumentException("You aren't allowed to apply a CustomLinkedRewardSet to an NRewardButton");
+ }
+
+ [HarmonyPatch(typeof(Reward), "HoverTips", MethodType.Getter)]
+ [HarmonyPostfix]
+ private static void AddHoverTip(Reward __instance, ref IEnumerable __result)
+ {
+ if (!CustomLinkedRewardSet.TryGetCustomLinkedRewardSet(__instance, out var customLinkedRewardSet)) return;
+ var list = __result.ToList();
+ list.Add(customLinkedRewardSet.HoverTip);
+ __result = list;
+ }
+}
+
+
+[HarmonyPatch]
+public static class CustomLinkedRewardSet_MultiplayerPatches
+{
+ [HarmonyPatch(typeof(RewardsSetSynchronizer), nameof(RewardsSetSynchronizer.SelectLocalReward))]
+ private static class RedirectNestedRewardSelection
+ {
+ // index must be top level reward, so we reroute to linked container
+ [HarmonyPrefix]
+ static bool Prefix(RewardsSetSynchronizer __instance, ref Task __result, ref Reward reward)
+ {
+ if (!CustomLinkedRewardSet.TryGetCustomLinkedRewardSet(reward, out var parent)) return true;
+ if (parent.SuccessfullySelected)
+ {
+ // Bundled sibling buttons re-enter here via NCustomLinkedRewardSet.GetReward after the set
+ // already completed.
+ // Report success without reselecting.
+ __result = Task.FromResult(true);
+ return false;
+ }
+ // We can not use the existing Synchronisation method for Linked rewards.
+ // The game only sends the index of the reward, no information about anything else.
+ // Exclusive needs to know which nested reward was chosen
+ // Bundled needs to know which nested reward to resolve first
+ // (nested CardReward choices sync in resolution order via PlayerChoiceSynchronizer,
+ // so all clients must resolve in the same order).
+ // Using a custom message to bypass base games RewardsSetSynchronizer completely.
+ var rewardStateForPlayer = __instance.GetRewardStateForPlayer(__instance.LocalPlayer);
+ var rewardsSetState = rewardStateForPlayer.rewardsStack.Last();
+ var containerIndex = rewardsSetState.set.Rewards.IndexOf(parent);
+ var nestedIndex = parent.Rewards.ToList().IndexOf(reward);
+
+ parent.SetPendingSelection(reward);
+ __result = __instance.SelectRewardForPlayer(rewardsSetState, parent); // apply locally immediately
+
+ CustomMessageWrapper.Send(new CustomLinkedRewardChoiceMessage
+ {
+ setId = rewardsSetState.set.Id,
+ containerIndex = containerIndex,
+ nestedIndex = nestedIndex
+ });
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Common/Rewards/LinkedRewardSet/LinkedRewardType.cs b/Common/Rewards/LinkedRewardSet/LinkedRewardType.cs
new file mode 100644
index 00000000..3514b21f
--- /dev/null
+++ b/Common/Rewards/LinkedRewardSet/LinkedRewardType.cs
@@ -0,0 +1,20 @@
+namespace BaseLib.Common.Rewards.LinkedRewardSet;
+
+///
+/// Reward gain rules for the Linked Reward
+///
+public enum LinkedRewardType
+{
+ ///
+ /// Do not use
+ ///
+ None,
+ ///
+ /// You may only choose 1 option
+ ///
+ Exclusive,
+ ///
+ /// You either choose All or No options
+ ///
+ Bundled
+}