commit 3449f141fa14137311c3ad08e63e67ff890b7bbc Author: framewrk Date: Sat Sep 19 14:39:03 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4871187 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Build output +bin/ +obj/ + +# Visual Studio files +*.user +*.suo +*.userosscache +*.sln.docstates + +# NuGet packages +*.nupkg +*.snupkg +packages/ +*.nuspec + +# Logs +*.log + +# Debug/Release folders +Debug/ +Release/ + +# VS Code settings +.vscode/ +.vs/ + +# Other +*.DS_Store +Thumbs.db +/blackjack_graph +/Assembly-CSharp +/Unity_Tools +/GameAssemblyReference diff --git a/AutoBlackjack/AutoBlackjack.csproj b/AutoBlackjack/AutoBlackjack.csproj new file mode 100644 index 0000000..21ab893 --- /dev/null +++ b/AutoBlackjack/AutoBlackjack.csproj @@ -0,0 +1,52 @@ + + + + net6.0 + disable + disable + latest + false + false + + + + C:\Program Files (x86)\Steam\steamapps\common\Monopoly Poker\BepInEx\core + C:\Program Files (x86)\Steam\steamapps\common\Monopoly Poker\BepInEx\interop + + + + + + $(BepInExDir)\BepInEx.Core.dll + false + + + $(BepInExDir)\BepInEx.Unity.IL2CPP.dll + false + + + $(BepInExDir)\0Harmony.dll + false + + + $(BepInExDir)\Il2CppInterop.Runtime.dll + false + + + $(InteropDir)\UnityEngine.CoreModule.dll + false + + + $(InteropDir)\Assembly-CSharp.dll + false + + + $(InteropDir)\Il2Cppmscorlib.dll + false + + + $(InteropDir)\Il2CppSystem.Core.dll + false + + + diff --git a/AutoBlackjack/BlackjackStatsJsonDatabase.cs b/AutoBlackjack/BlackjackStatsJsonDatabase.cs new file mode 100644 index 0000000..df80357 --- /dev/null +++ b/AutoBlackjack/BlackjackStatsJsonDatabase.cs @@ -0,0 +1,216 @@ +using BepInEx; +using BepInEx.Unity.IL2CPP; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AutoBlackjack; + +/// +/// Manages JSON database for tracking blackjack statistics. +/// Tracks: bet amount, date/time, chip balance, and round outcome. +/// +/// Logger doesnt work +/// +public class BlackjackStatsJsonDatabase : IDisposable +{ + private readonly string _jsonPath; + private List _records; + private bool _disposed; + + public BlackjackStatsJsonDatabase(string jsonPath) + { + _jsonPath = jsonPath; + Initialize(); + } + + private void Initialize() + { + try + { + // Ensure directory exists + string directory = Path.GetDirectoryName(_jsonPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // Load existing records or create new list + if (File.Exists(_jsonPath)) + { + string jsonContent = File.ReadAllText(_jsonPath); + _records = JsonSerializer.Deserialize>(jsonContent) + ?? new List(); + //Log.LogInfo($"Loaded {_records.Count} records from JSON database"); + } + else + { + _records = new List(); + SaveToFile(); + //Log.LogInfo($"Created new JSON database at: {_jsonPath}"); + } + } + catch (Exception ex) + { + //Log.Error($"Failed to initialize JSON database: {ex}"); + _records = new List(); + } + } + + /// + /// Log a blackjack round with statistics. + /// + public void LogRound(long betAmount, long chipBalance, string outcome = null, + int playerTotal = 0, int dealerUpcard = 0, string action = null, long profit = 0) + { + try + { + var record = new BlackjackRoundRecord + { + Id = _records.Count > 0 ? _records[^1].Id + 1 : 1, + Date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + BetAmount = betAmount, + ChipBalance = chipBalance, + Outcome = outcome ?? "", + PlayerTotal = playerTotal, + DealerUpcard = dealerUpcard, + Action = action ?? "", + Profit = profit + }; + + _records.Add(record); + SaveToFile(); + } + catch (Exception ex) + { + //Log.Error($"Failed to log round to JSON database: {ex}"); + } + } + + /// + /// Save records to JSON file. + /// + private void SaveToFile() + { + try + { + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + + string jsonContent = JsonSerializer.Serialize(_records, options); + File.WriteAllText(_jsonPath, jsonContent); + } + catch (Exception ex) + { + //Log.Error($"Failed to save JSON database: {ex}"); + } + } + + /// + /// Get total number of rounds played. + /// + public long GetTotalRounds() + { + return _records?.Count ?? 0; + } + + /// + /// Get total profit/loss across all rounds. + /// + public long GetTotalProfit() + { + try + { + if (_records == null || _records.Count == 0) + return 0; + + long total = 0; + foreach (var record in _records) + { + total += record.Profit; + } + return total; + } + catch (Exception ex) + { + //Log.Error($"Failed to get total profit: {ex}"); + return 0; + } + } + + /// + /// Get statistics summary. + /// + public void LogStatsSummary() + { + try + { + long totalRounds = GetTotalRounds(); + long totalProfit = GetTotalProfit(); + + //Log.Info("=== Blackjack Stats Summary (JSON) ==="); + //Log.Info($"Total Rounds: {totalRounds}"); + //Log.Info($"Total Profit: {totalProfit:+#;-#;0} chips"); + //if (totalRounds > 0) + //Log.Info($"Average Profit per Round: {(double)totalProfit / totalRounds:F2} chips"); + //Log.Info("======================================="); + } + catch (Exception ex) + { + //Log.Error($"Failed to log stats summary: {ex}"); + } + } + + public void Dispose() + { + if (_disposed) return; + + try + { + SaveToFile(); + } + catch (Exception ex) + { + //Log.Error($"Error disposing JSON database: {ex}"); + } + + _disposed = true; + } +} + +/// +/// Represents a single blackjack round record. +/// +public class BlackjackRoundRecord +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("date")] + public string Date { get; set; } + + [JsonPropertyName("betAmount")] + public long BetAmount { get; set; } + + [JsonPropertyName("chipBalance")] + public long ChipBalance { get; set; } + + [JsonPropertyName("outcome")] + public string Outcome { get; set; } + + [JsonPropertyName("playerTotal")] + public int PlayerTotal { get; set; } + + [JsonPropertyName("dealerUpcard")] + public int DealerUpcard { get; set; } + + [JsonPropertyName("action")] + public string Action { get; set; } + + [JsonPropertyName("profit")] + public long Profit { get; set; } +} \ No newline at end of file diff --git a/AutoBlackjack/Plugin.cs b/AutoBlackjack/Plugin.cs new file mode 100644 index 0000000..4d874bf --- /dev/null +++ b/AutoBlackjack/Plugin.cs @@ -0,0 +1,1137 @@ +using BepInEx; +using BepInEx.Unity.IL2CPP; +using HarmonyLib; +using System; +using System.Reflection; +using UnityEngine; +using static Il2CppSystem.Reflection.RuntimePropertyInfo; + +namespace AutoBlackjack; + +/// +/// BepInEx 6 IL2CPP plugin that automatically plays Blackjack in Monopoly Poker. +/// +/// BlackjackCardValue enum (confirmed from runtime dump): +/// Two=0, Three=1, Four=2, Five=3, Six=4, Seven=5, +/// Eight=6, Nine=7, Ten=8, Jack=9, Queen=10, King=11, Ace=12, Unknown=-1 +/// +/// Round flow (confirmed from BlackjackView method dump): +/// ShowBlackjackBetInterface -> auto-bet fires here +/// HandleOnInitRound -> initial deal: dealer upcard + player cards Hook 2 +/// HandleOnNextDecision -> player decision Hook 3 +/// HandleOnDealerCards -> dealer reveals full hand (AFTER player acts) +/// HandleOnEndRound -> results +/// +/// Dealer upcard data path (confirmed from runtime dumps): +/// BlackjackRoundData.get_dealerHand() -> BlackjackHandData +/// .get_handValuesPerCard()[upcardIdx] -> List (possible values for that card) +/// max(inner list) -> direct blackjack point value (2-11, where 11 = Ace) +/// +/// Note: get_Value() on BlackjackHandData cards always returns 0. +/// Those are data-layer objects; actual values live in handValuesPerCard. +/// +/// Hooks: +/// 1. BlackjackDecisionView.ShowBlackjackBetInterface -> auto-bet minimum +/// 2. BlackjackView.HandleOnInitRound -> store dealer upcard +/// 3. BlackjackView.HandleOnNextDecision -> auto-decide hit/stand +/// +[BepInPlugin(PluginGUID, PluginName, PluginVersion)] +public class Plugin : BasePlugin +{ + public const string PluginGUID = "com.mod.autoblackjack"; + public const string PluginName = "Auto Blackjack"; + public const string PluginVersion = "1.0.0"; + public const string GameVersion = "0.0"; // for future compatibility checks + + public static bool DEBUG = true; // set to false to suppress diagnostic logs + public static bool START_BETTING = false; + + private Harmony _harmony; + private static BepInEx.Logging.ManualLogSource _log; + private static bool _pluginEnabled = true; // Runtime enable/disable flag + private static BlackjackStatsJsonDatabase _statsDatabase; // JSON stats tracker + + // ── Reflection handles: shared ────────────────────────────── + private static bool _sharedResolved; + private static MethodInfo _blackjackAction; // BlackjackEvents.BlackjackAction(String, Int32, Int64) + private static MethodInfo _getActionBet; // BlackjackUserDecision.get_ActionBet() [static] + private static MethodInfo _getActionHit; // BlackjackUserDecision.get_ActionHit() [static] + private static MethodInfo _getActionStand; // BlackjackUserDecision.get_ActionStand() [static] + private static MethodInfo _getActionDouble; // BlackjackUserDecision.get_ActionDouble() [static] + private static MethodInfo _getActionSplit; // BlackjackUserDecision.get_ActionSplit() [static] + + // ── Reflection handles: bet hook ───────────────────────────── + private static bool _betResolved; + private static MethodInfo _getBets; // BlackjackAskBetData.get_bets() + private static MethodInfo _getDecViewEvents; // BlackjackDecisionView.get_m_BlackjackEvents() + private static MethodInfo _hideBetInterface; // BlackjackDecisionView.HideBlackjackBetInterface() + + // ── Reflection handles: decision hook ─────────────────────── + private static bool _decResolved; + // From BlackjackNextDecisionData + private static MethodInfo _getDecHandId; // get_handId() -> Int32 + private static MethodInfo _getDecBetAmt; // get_handValue() -> Int64 (chip bet amount) + // From BlackjackView + private static MethodInfo _getBjViewEvents; // get_m_BlackjackEvents() + private static MethodInfo _getHandViews; // get_m_HandViews() -> List + private static MethodInfo _getDecInterface; // get_m_DecisionInterface() -> BlackjackDecisionView + // From BlackjackDecisionView + private static MethodInfo _hideDecision; // HideBlackjackDecision() + private static MethodInfo _getDecUIGameObject; // get_m_BlackjackDecisionInterface() -> GameObject + // From BlackjackHandView + private static MethodInfo _getHVHandId; // get_HandId() -> Int32 + private static MethodInfo _getHVCards; // get_m_Cards() -> List + // From BlackjackCardData + private static MethodInfo _getCardValue; // get_Value() -> BlackjackCardValue enum + + // ── Reflection handles: dealer upcard (init-round hook) ───── + private static bool _dealerResolved; + private static MethodInfo _getRoundDealerHand; // BlackjackRoundData.get_dealerHand() -> BlackjackHandData + private static MethodInfo _getHandValuesPerCard; // BlackjackHandData.get_handValuesPerCard() -> List> + private static MethodInfo _getHandValues; // BlackjackHandData.get_handValues() -> List (fallback) + + // ── Reflection handles: end round hook ────────────────────── + private static bool _endRoundResolved; + private static MethodInfo _getEndRoundData; // BlackjackEndRoundData.get_roundResults() -> List + private static MethodInfo _getRoundResultHandId; // BlackjackRoundResult.get_handId() -> Int32 + private static MethodInfo _getRoundResultOutcome; // BlackjackRoundResult.get_outcome() -> String or enum + private static MethodInfo _getRoundResultChips; // BlackjackRoundResult.get_chips() -> Int64 + private static MethodInfo _getPlayerChips; // Try to find player chip balance getter + + // ── Round state tracking ───────────────────────────────────── + // Direct blackjack point value (2-11 where 11=Ace), -1 = not yet received. + // Set in OnInitRound; read in OnHandleNextDecision. + private static int _dealerUpcardValue = -1; + private static long _roundStartChips = 0; // Chip balance at round start + private static long _roundBetAmount = 0; // Total bet amount for the round + private static int _roundPlayerTotal = 0; // Player hand total (first hand if split) + private static string _roundAction = ""; // Last action taken + // ── Session tracking ───────────────────────────────────────── + private static long _sessionNetProfit = 0; // Cumulative net profit for entire session + private static int _sessionRoundsPlayed = 0; // Total rounds played this session + + public static int bet_index = 1; + public override void Load() + { + _harmony = new Harmony(PluginGUID); + + // Initialize static logger for use from static methods + _log = Log; + + Log.LogInfo($"{PluginName} v{PluginVersion} loading..."); + + // Initialize JSON stats database + string dbPath = System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), + "blackjack_stats.json"); + _statsDatabase = new BlackjackStatsJsonDatabase(dbPath); + Log.LogInfo($"Stats database initialized at: {dbPath}"); + + var patchTargets = new (string typeName, string methodName, string handler, bool isPostfix)[] + { + ("YoudaGames.MonopolyPoker.Games.View.BlackjackDecisionView", "ShowBlackjackBetInterface", nameof(OnShowBetInterface), true), + ("YoudaGames.MonopolyPoker.Games.View.BlackjackView", "HandleOnInitRound", nameof(OnInitRound), true), + ("YoudaGames.MonopolyPoker.Games.View.BlackjackView", "HandleOnNextDecision", nameof(OnHandleNextDecision), true), + ("YoudaGames.MonopolyPoker.Games.View.BlackjackView", "HandleOnEndRound", nameof(OnEndRound), true) + }; + + foreach (var (typeName, methodName, handler, isPostfix) in patchTargets) + { + PatchMethod( + typeName, + methodName, + new HarmonyMethod(typeof(Plugin).GetMethod(handler, BindingFlags.Static | BindingFlags.NonPublic)), + isPostfix: isPostfix); + } + + Log.LogInfo($"{PluginName} loaded."); + + } + + public override bool Unload() + { + try + { + // Log final session summary + if (_sessionRoundsPlayed > 0) + { + Log.LogInfo("-------------------------------------------------------"); + Log.LogInfo($"SESSION SUMMARY:"); + Log.LogInfo($" Rounds played: {_sessionRoundsPlayed}"); + Log.LogInfo($" NET profit: {_sessionNetProfit:+#;-#;0} chips"); + if (_sessionRoundsPlayed > 0) + { + double avgProfit = (double)_sessionNetProfit / _sessionRoundsPlayed; + Log.LogInfo($" Average profit per round: {avgProfit:+0.00;-0.00;0.00} chips"); + } + Log.LogInfo("-------------------------------------------------------"); + } + + // Log final stats summary + _statsDatabase?.LogStatsSummary(); + + // Cleanup + _statsDatabase?.Dispose(); + _harmony?.UnpatchSelf(); + + Log.LogInfo($"{PluginName} unloaded."); + return true; + } + catch (Exception ex) + { + Log.LogError($"Error during unload: {ex}"); + return false; + } + } + + // --------------------------------------------------------------- + // Reflection resolvers + // --------------------------------------------------------------- + + private static bool ResolveShared() + { + if (_sharedResolved) return true; + try + { + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + var eventsType = FindType("YoudaGames.MonopolyPoker.Games.BlackjackEvents"); + var userDecisionType = FindType("YoudaGames.MonopolyPoker.Games.Data.BlackjackUserDecision"); + + _blackjackAction = eventsType?.GetMethod("BlackjackAction", bindAll); + _getActionBet = userDecisionType?.GetMethod("get_ActionBet", bindAll); + _getActionHit = userDecisionType?.GetMethod("get_ActionHit", bindAll); + _getActionStand = userDecisionType?.GetMethod("get_ActionStand", bindAll); + _getActionDouble = userDecisionType?.GetMethod("get_ActionDouble", bindAll); + _getActionSplit = userDecisionType?.GetMethod("get_ActionSplit", bindAll); + + _sharedResolved = _blackjackAction != null && _getActionBet != null && + _getActionHit != null && _getActionStand != null && + _getActionDouble != null && _getActionSplit != null; + + if (!_sharedResolved) + { + _log.LogWarning("Shared resolve failed:"); + if (_blackjackAction == null) _log.LogWarning(" - BlackjackAction"); + if (_getActionBet == null) _log.LogWarning(" - get_ActionBet"); + if (_getActionHit == null) _log.LogWarning(" - get_ActionHit"); + if (_getActionStand == null) _log.LogWarning(" - get_ActionStand"); + if (_getActionDouble == null) _log.LogWarning(" - get_ActionDouble"); + if (_getActionSplit == null) _log.LogWarning(" - get_ActionSplit"); + } + return _sharedResolved; + } + catch (Exception ex) { _log.LogError($"ResolveShared: {ex}"); return false; } + } + + private static bool ResolveBet() + { + if (_betResolved) return true; + if (!ResolveShared()) return false; + try + { + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + var askBetType = FindType("YoudaGames.MonopolyPoker.Games.Data.BlackjackAskBetData"); + var decViewType = FindType("YoudaGames.MonopolyPoker.Games.View.BlackjackDecisionView"); + + _getBets = askBetType?.GetMethod("get_bets", bindAll); + _getDecViewEvents = decViewType?.GetMethod("get_m_BlackjackEvents", bindAll); + _hideBetInterface = decViewType?.GetMethod("HideBlackjackBetInterface", bindAll); + + _betResolved = _getBets != null && _getDecViewEvents != null; + + if (!_betResolved) + { + _log.LogWarning("Bet resolve failed:"); + if (_getBets == null) _log.LogWarning(" - get_bets"); + if (_getDecViewEvents == null) _log.LogWarning(" - DecisionView.get_m_BlackjackEvents"); + } + return _betResolved; + } + catch (Exception ex) { _log.LogError($"ResolveBet: {ex}"); return false; } + } + + private static bool ResolveDecision() + { + if (_decResolved) return true; + if (!ResolveShared()) return false; + try + { + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + var nextDecType = FindType("YoudaGames.MonopolyPoker.Games.Data.BlackjackNextDecisionData"); + var bjViewType = FindType("YoudaGames.MonopolyPoker.Games.View.BlackjackView"); + var decViewType = FindType("YoudaGames.MonopolyPoker.Games.View.BlackjackDecisionView"); + var handViewType = FindType("YoudaGames.MonopolyPoker.Games.View.BlackjackHandView"); + var cardDataType = FindType("YoudaGames.MonopolyPoker.Games.Data.BlackjackCardData"); + + _getDecHandId = nextDecType?.GetMethod("get_handId", bindAll); + _getDecBetAmt = nextDecType?.GetMethod("get_handValue", bindAll); + _getBjViewEvents = bjViewType?.GetMethod("get_m_BlackjackEvents", bindAll); + _getHandViews = bjViewType?.GetMethod("get_m_HandViews", bindAll); + _getDecInterface = bjViewType?.GetMethod("get_m_DecisionInterface", bindAll); + _hideDecision = decViewType?.GetMethod("HideBlackjackDecision", bindAll); + _getDecUIGameObject = decViewType?.GetMethod("get_m_BlackjackDecisionInterface", bindAll); + _getHVHandId = handViewType?.GetMethod("get_HandId", bindAll); + _getHVCards = handViewType?.GetMethod("get_m_Cards", bindAll); + _getCardValue = cardDataType?.GetMethod("get_Value", bindAll); + + _decResolved = _getDecHandId != null && _getDecBetAmt != null && + _getBjViewEvents != null && _getHandViews != null && + _getHVHandId != null && _getHVCards != null && + _getCardValue != null; + + if (_decResolved) + _log.LogInfo("All decision reflection targets resolved."); + else + { + _log.LogWarning("Decision resolve failed:"); + + if (_getDecHandId == null) _log.LogWarning(" - NextDecisionData.get_handId"); + if (_getDecBetAmt == null) _log.LogWarning(" - NextDecisionData.get_handValue"); + if (_getBjViewEvents == null) _log.LogWarning(" - BlackjackView.get_m_BlackjackEvents"); + if (_getHandViews == null) _log.LogWarning(" - BlackjackView.get_m_HandViews"); + if (_getDecInterface == null) _log.LogWarning(" - BlackjackView.get_m_DecisionInterface"); + if (_hideDecision == null) _log.LogWarning(" - HideBlackjackDecision"); + if (_getDecUIGameObject == null) _log.LogWarning(" - get_m_BlackjackDecisionInterface"); + if (_getHVHandId == null) _log.LogWarning(" - HandView.get_HandId"); + if (_getHVCards == null) _log.LogWarning(" - HandView.get_m_Cards"); + if (_getCardValue == null) _log.LogWarning(" - CardData.get_Value"); + } + return _decResolved; + } + catch (Exception ex) { _log.LogError($"ResolveDecision: {ex}"); return false; } + } + + private static bool ResolveDealer() + { + if (_dealerResolved) return true; + try + { + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + var roundDataType = FindType("YoudaGames.MonopolyPoker.Games.Data.BlackjackRoundData"); + var handDataType = FindType("YoudaGames.MonopolyPoker.Games.Data.BlackjackHandData"); + + _getRoundDealerHand = roundDataType?.GetMethod("get_dealerHand", bindAll); + // Primary: List> — inner list holds possible values per card (e.g. [1,11] for Ace) + _getHandValuesPerCard = handDataType?.GetMethod("get_handValuesPerCard", bindAll); + // Fallback: List — flat per-card point values + _getHandValues = handDataType?.GetMethod("get_handValues", bindAll); + + _dealerResolved = _getRoundDealerHand != null && + (_getHandValuesPerCard != null || _getHandValues != null); + + if (!_dealerResolved) + { + _log.LogWarning("Dealer resolve failed:"); + if (_getRoundDealerHand == null) _log.LogWarning(" - BlackjackRoundData.get_dealerHand"); + if (_getHandValuesPerCard == null) _log.LogWarning(" - BlackjackHandData.get_handValuesPerCard"); + if (_getHandValues == null) _log.LogWarning(" - BlackjackHandData.get_handValues"); + } + return _dealerResolved; + } + catch (Exception ex) { _log.LogError($"ResolveDealer: {ex}"); return false; } + } + + private static bool ResolveEndRound() + { + if (_endRoundResolved) return true; + try + { + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + var bjViewType = FindType("YoudaGames.MonopolyPoker.Games.View.BlackjackView"); + + // Try various method names for getting player chips + if (bjViewType != null) + { + _getPlayerChips = bjViewType.GetMethod("get_PlayerChips", bindAll) + ?? bjViewType.GetMethod("get_playerChips", bindAll) + ?? bjViewType.GetMethod("get_Chips", bindAll) + ?? bjViewType.GetMethod("get_chips", bindAll); + } + + // Don't try to resolve EndRoundData or RoundResult types - use fully dynamic discovery + _log.LogInfo("Using fully dynamic resolution for EndRound data (types not pre-resolvable)"); + + // Mark as resolved - we'll handle everything dynamically in OnEndRound + _endRoundResolved = true; + + return _endRoundResolved; + } + catch (Exception ex) { _log.LogError($"ResolveEndRound: {ex}"); return false; } + } + + // --------------------------------------------------------------- + // Hook : Auto-bet + // --------------------------------------------------------------- + private static void OnShowBetInterface(object __instance, object __0) + { + if (!_pluginEnabled) return; // Check if plugin is enabled + + try + { + if (!ResolveBet()) return; + + var betsList = _getBets.Invoke(__0, null); + if (betsList == null) { _log.LogWarning("Auto-bet: bets list is null"); return; } + + int count = Convert.ToInt32(betsList.GetType().GetProperty("Count")?.GetValue(betsList)); + if (count == 0) { _log.LogWarning("Auto-bet: bets list empty"); return; } + + var getItem = betsList.GetType().GetMethod("get_Item"); + + // Bet amount is in the "Value" property of the bet data objects; we want the minimum bet (index 0). + + long betAmount = Convert.ToInt64(getItem?.Invoke(betsList, new object[] { bet_index })); + + var events = _getDecViewEvents.Invoke(__instance, null); + if (events == null) { _log.LogWarning("Auto-bet: events null"); return; } + + _log.LogInfo($">>> AUTO-BET: {betAmount} chips <<<"); + _blackjackAction.Invoke(events, new object[] { _getActionBet.Invoke(null, null), 0, betAmount }); + _hideBetInterface?.Invoke(__instance, null); + } + catch (Exception ex) { _log.LogError($"OnShowBetInterface: {ex}"); } + } + + // --------------------------------------------------------------- + // Hook : Capture dealer upcard from initial deal + // --------------------------------------------------------------- + /// + /// Postfix on BlackjackView.HandleOnInitRound(BlackjackRoundData). + /// + /// Dealer upcard reading strategy (confirmed from runtime dumps): + /// PRIMARY: handValuesPerCard[upcardIdx] is List of possible values. + /// max(list) gives 11 for Ace, face value for others. + /// FALLBACK: handValues[upcardIdx] is a direct int point value. + /// + /// upcardIdx = 1 (American deal: hole first, upcard second). + /// If only 1 card present, upcardIdx = 0. + /// + private static void OnInitRound(object __instance, object __0) + { + if (!_pluginEnabled) return; // Check if plugin is enabled + + try + { + _dealerUpcardValue = -1; // reset state at start of round + _roundStartChips = 0; + _roundBetAmount = 0; + _roundPlayerTotal = 0; + _roundAction = ""; + + // Try to get player chip balance + if (_getPlayerChips != null) + { + try + { + var chips = _getPlayerChips.Invoke(__instance, null); + if (chips != null) + _roundStartChips = Convert.ToInt64(chips); + } + catch { } + } + + if (__0 == null) { _log.LogWarning("OnInitRound: BlackjackRoundData is null"); return; } + if (!ResolveDealer()) return; + + var dealerHand = _getRoundDealerHand.Invoke(__0, null); + if (dealerHand == null) { _log.LogWarning("OnInitRound: dealerHand null"); return; } + + bool readSuccess = false; + + // ── Primary: handValuesPerCard — List> ────────────────── + if (_getHandValuesPerCard != null) + { + var hvpcList = _getHandValuesPerCard.Invoke(dealerHand, null); + if (hvpcList != null) + { + int outerCount = Convert.ToInt32(hvpcList.GetType().GetProperty("Count")?.GetValue(hvpcList)); + var outerGetItem = hvpcList.GetType().GetMethod("get_Item"); + int upcardIdx = outerCount > 1 ? 1 : 0; + + if (DEBUG) + { + _log.LogInfo($"Dealer handValuesPerCard ({outerCount} cards):"); + for (int i = 0; i < outerCount; i++) + { + var inner = outerGetItem?.Invoke(hvpcList, new object[] { i }); + if (inner == null) { _log.LogInfo($" [{i}] = null"); continue; } + int ic = Convert.ToInt32(inner.GetType().GetProperty("Count")?.GetValue(inner)); + var igi = inner.GetType().GetMethod("get_Item"); + string s = ""; + for (int k = 0; k < ic; k++) s += (k > 0 ? "," : "") + igi?.Invoke(inner, new object[] { k }); + _log.LogInfo($" [{i}]{(i == upcardIdx ? "upcard" : "")} = [{s}]"); + } + } + + if (upcardIdx < outerCount) + { + var innerList = outerGetItem?.Invoke(hvpcList, new object[] { upcardIdx }); + if (innerList != null) + { + int ic = Convert.ToInt32(innerList.GetType().GetProperty("Count")?.GetValue(innerList)); + var igi = innerList.GetType().GetMethod("get_Item"); + int maxVal = 0; + for (int k = 0; k < ic; k++) + { + int v = Convert.ToInt32(igi?.Invoke(innerList, new object[] { k })); + if (v > maxVal) maxVal = v; + } + if (maxVal > 0) { _dealerUpcardValue = maxVal; readSuccess = true; } + } + } + } + } + + // ── Fallback: handValues — List ───────────────────────────── + if (!readSuccess && _getHandValues != null) + { + var hvList = _getHandValues.Invoke(dealerHand, null); + if (hvList != null) + { + int c = Convert.ToInt32(hvList.GetType().GetProperty("Count")?.GetValue(hvList)); + var gi = hvList.GetType().GetMethod("get_Item"); + int upcardIdx = c > 1 ? 1 : 0; + + if (DEBUG) + { + _log.LogInfo($"Dealer handValues fallback ({c} entries):"); + for (int i = 0; i < c; i++) + _log.LogInfo($" [{i}]{(i == upcardIdx ? "upcard" : "")} = {gi?.Invoke(hvList, new object[] { i })}"); + } + + if (upcardIdx < c) + { + int v = Convert.ToInt32(gi?.Invoke(hvList, new object[] { upcardIdx })); + if (v > 0) { _dealerUpcardValue = v; readSuccess = true; } + } + } + } + + if (readSuccess) + _log.LogInfo($"Dealer upcard value stored: {_dealerUpcardValue}"); + else + { + _log.LogError("OnInitRound: could not determine dealer upcard value. Disabling plugin."); + _pluginEnabled = false; + } + } + catch (Exception ex) { _log.LogError($"OnInitRound: {ex}"); } + } + + // --------------------------------------------------------------- + // Hook : Auto-decide hit/stand using basic strategy + // --------------------------------------------------------------- + private static void OnHandleNextDecision(object __instance, object __0, object __1) + { + if (!_pluginEnabled) return; // Check if plugin is enabled + + try + { + if (!ResolveDecision()) return; + + if (DEBUG) DumpRoundData(__0); + + // ── Check if decision UI is showing (only active for client player) ── + var decIntf = _getDecInterface?.Invoke(__instance, null); + if (decIntf == null) return; + + if (_getDecUIGameObject != null) + { + var decUIGO = _getDecUIGameObject.Invoke(decIntf, null); + if (decUIGO != null) + { + var activeSelfProp = decUIGO.GetType().GetProperty("activeSelf"); + if (activeSelfProp != null) + { + bool isActive = Convert.ToBoolean(activeSelfProp.GetValue(decUIGO)); + if (!isActive) return; + } + } + } + + // ── Read decision data ── + int handId = Convert.ToInt32(_getDecHandId.Invoke(__0, null)); + long betAmt = Convert.ToInt64(_getDecBetAmt.Invoke(__0, null)); + int dealerValue = _dealerUpcardValue; // direct point value (2-11), -1 = unknown + + // ── Player hand cards + analysis ── + int handTotal = CalculateHandTotal(__instance, handId, out bool isSoft, out int[] cardEnums); + if (handTotal < 0) + { + _log.LogError($"CRITICAL: Could not read cards for hand {handId} - disabling plugin"); + _pluginEnabled = false; // Disable plugin entirely + return; // Exit hook + } + + // Store round info for end-round logging + if (_roundBetAmount == 0) // First decision of the round + { + _roundBetAmount = betAmt; + _roundPlayerTotal = handTotal; + + // Capture starting chips from decision data + try + { + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + var dataType = __0.GetType(); + var chipsMethod = dataType.GetMethod("get_playersChips", bindAll) + ?? dataType.GetMethod("get_PlayerChips", bindAll); + + if (chipsMethod != null) + { + var chips = chipsMethod.Invoke(__0, null); + if (chips != null) + { + long currentChips = Convert.ToInt64(chips); + _roundStartChips = currentChips; // Track chips AFTER bet is deducted for net profit + _log.LogInfo($"Captured starting chips (after bet): current={currentChips}, bet={betAmt}"); + } + } + else + { + _log.LogWarning("Could not find get_playersChips method"); + } + } + catch (Exception ex) + { + _log.LogWarning($"Failed to capture starting chips: {ex.Message}"); + } + } + + // ── Check for pair (for splitting) ── + bool isPair = cardEnums.Length == 2 && + CardEnumToBlackjackValue(cardEnums[0]) == + CardEnumToBlackjackValue(cardEnums[1]); + int pairValue = isPair ? CardEnumToBlackjackValue(cardEnums[0]) : 0; + + // ── Basic strategy decision ── + var events = _getBjViewEvents.Invoke(__instance, null); + if (events == null) { _log.LogWarning("Auto-decide: events null"); return; } + + string action = dealerValue > 0 + ? BasicStrategyAction(handTotal, isSoft, dealerValue, isPair, pairValue, cardEnums.Length) + : (handTotal <= 11 ? "HIT" : "STAND"); // fallback if dealer upcard still unknown + + object actionStr; + string handDesc = $"player={handTotal}{(isSoft ? "(soft)" : "")}{(isPair ? $" pair={pairValue}" : "")} dealer={dealerValue}"; + + switch (action) + { + case "SPLIT": + actionStr = _getActionSplit.Invoke(null, null); + _log.LogInfo($">>> Hand {handId}: {handDesc} -> SPLIT <<<"); + break; + case "DOUBLE": + actionStr = _getActionDouble.Invoke(null, null); + _log.LogInfo($">>> Hand {handId}: {handDesc} -> DOUBLE <<<"); + break; + case "HIT": + actionStr = _getActionHit.Invoke(null, null); + _log.LogInfo($">>> Hand {handId}: {handDesc} -> HIT <<<"); + break; + default: // STAND + actionStr = _getActionStand.Invoke(null, null); + _log.LogInfo($">>> Hand {handId}: {handDesc} -> STAND <<<"); + break; + } + + _blackjackAction.Invoke(events, new object[] { actionStr, handId, betAmt }); + _hideDecision?.Invoke(decIntf, null); + + // Store last action for end-round logging + _roundAction = action; + } + catch (Exception ex) { _log.LogError($"OnHandleNextDecision: {ex}"); } + } + + // --------------------------------------------------------------- + // Hook : Capture round results and log to database + // --------------------------------------------------------------- + /// + /// Postfix on BlackjackView.HandleOnEndRound(BlackjackEndRoundData). + /// Captures final chip balance, outcome, and profit to log complete round data. + /// + private static void OnEndRound(object __instance, object __0) + { + if (!_pluginEnabled) return; + + try + { + if (__0 == null) { _log.LogWarning("OnEndRound: parameter is null"); return; } + ResolveEndRound(); // Initialize resolver + + // Dump the parameter type to discover correct structure + if (DEBUG) + { + _log.LogInfo($"OnEndRound parameter type: {__0.GetType().FullName}"); + DumpRoundData(__0); + } + + // Extract data from BlackjackEndRoundData + string outcome = "UNKNOWN"; + long finalChips = _roundStartChips; + long amountWon = 0; + long amountBet = 0; + bool busted = false; + bool blackjack = false; + bool foundData = false; + + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + // Look for the "hands" collection in BlackjackEndRoundData + try + { + var paramType = __0.GetType(); + var handsMethod = paramType.GetMethod("get_hands", bindAll); + + if (handsMethod != null) + { + var hands = handsMethod.Invoke(__0, null); + if (hands != null) + { + var handsType = hands.GetType(); + var countProp = handsType.GetProperty("Count"); + if (countProp != null) + { + int count = Convert.ToInt32(countProp.GetValue(hands)); + _log.LogInfo($"Found {count} hand(s) in round results"); + + if (count > 0) + { + var getItem = handsType.GetMethod("get_Item"); + var firstHand = getItem?.Invoke(hands, new object[] { 0 }); + + if (firstHand != null) + { + var handType = firstHand.GetType(); + + // Extract key data from BlackjackHandData + var amountWonMethod = handType.GetMethod("get_amountWon", bindAll); + var amountBetMethod = handType.GetMethod("get_amountBet", bindAll); + var bustedMethod = handType.GetMethod("get_busted", bindAll); + var blackjackMethod = handType.GetMethod("get_blackjack", bindAll); + + if (amountWonMethod != null) + { + amountWon = Convert.ToInt64(amountWonMethod.Invoke(firstHand, null)); + _log.LogInfo($"Amount won: {amountWon}"); + } + + if (amountBetMethod != null) + { + amountBet = Convert.ToInt64(amountBetMethod.Invoke(firstHand, null)); + _log.LogInfo($"Amount bet: {amountBet}"); + } + + if (bustedMethod != null) + { + busted = Convert.ToBoolean(bustedMethod.Invoke(firstHand, null)); + _log.LogInfo($"Busted: {busted}"); + } + + if (blackjackMethod != null) + { + blackjack = Convert.ToBoolean(blackjackMethod.Invoke(firstHand, null)); + _log.LogInfo($"Blackjack: {blackjack}"); + } + + foundData = true; + + // Derive outcome from the data + if (blackjack) + outcome = "BLACKJACK"; + else if (busted) + outcome = "BUST"; + else if (amountWon > amountBet) + outcome = "WIN"; + else if (amountWon == amountBet) + outcome = "PUSH"; + else + outcome = "LOSE"; + + _log.LogInfo($"Derived outcome: {outcome}"); + } + } + } + } + } + else + { + _log.LogWarning("Could not find get_hands method on BlackjackEndRoundData"); + } + } + catch (Exception ex) + { + _log.LogWarning($"Error extracting round data: {ex.Message}"); + if (DEBUG) _log.LogError($"Stack trace: {ex.StackTrace}"); + } + + // Get current chip balance from BlackjackView + bool foundChips = false; + try + { + var viewType = __instance.GetType(); + var chipsMethod = viewType.GetMethod("get_playersChips", bindAll) + ?? viewType.GetMethod("get_PlayerChips", bindAll) + ?? viewType.GetMethod("get_playerChips", bindAll) + ?? viewType.GetMethod("get_Chips", bindAll) + ?? viewType.GetMethod("get_chips", bindAll); + + if (chipsMethod != null) + { + var chips = chipsMethod.Invoke(__instance, null); + if (chips != null) + { + finalChips = Convert.ToInt64(chips); + foundChips = true; + _log.LogInfo($"Got final chips from BlackjackView: {finalChips}"); + } + } + } + catch (Exception ex) + { + _log.LogWarning($"Could not get chip balance from view: {ex.Message}"); + } + + // Calculate profit + long profit = 0; + + if (foundData) + { + // Primary: Calculate net profit from amountWon and amountBet + // amountWon is the total returned (includes original bet if won, 0 if lost) + profit = amountWon - amountBet; + _log.LogInfo($"Calculated profit: {amountWon} (won) - {amountBet} (bet) = {profit}"); + + // Update final chips if we couldn't read it directly + if (!foundChips && _roundStartChips > 0) + { + finalChips = _roundStartChips + profit; + _log.LogInfo($"Calculated final chips: {_roundStartChips} + {profit} = {finalChips}"); + } + } + else + { + // Fallback: Calculate from chip balance change + if (foundChips && _roundStartChips > 0) + { + profit = finalChips - _roundStartChips; + _log.LogInfo($"Calculated profit from chip change: {finalChips} - {_roundStartChips} = {profit}"); + } + else + { + _log.LogWarning($"Could not calculate profit: foundData={foundData}, foundChips={foundChips}, startChips={_roundStartChips}, bet={_roundBetAmount}"); + } + } + + // Log to database + _statsDatabase?.LogRound( + betAmount: _roundBetAmount, + chipBalance: finalChips, + outcome: outcome, + playerTotal: _roundPlayerTotal, + dealerUpcard: _dealerUpcardValue, + action: _roundAction, + profit: profit); + + // Update session statistics + _sessionNetProfit += profit; + _sessionRoundsPlayed++; + + _log.LogInfo($"Round complete: bet={_roundBetAmount}, outcome={outcome}, profit={profit:+#;-#;0}, balance={finalChips}"); + _log.LogInfo($"SESSION: {_sessionRoundsPlayed} rounds, NET profit={_sessionNetProfit:+#;-#;0} ---"); + + // Reset round state + _roundBetAmount = 0; + _roundStartChips = 0; + _roundPlayerTotal = 0; + _roundAction = ""; + } + catch (Exception ex) { _log.LogError($"OnEndRound: {ex}"); } + } + + // --------------------------------------------------------------- + // Basic strategy (full strategy with double/split) + // --------------------------------------------------------------- + /// + /// Returns "HIT", "STAND", "DOUBLE", or "SPLIT". + /// dealerValue: 2-11 where 11 = Ace showing. + /// isSoft: player hand contains an Ace still counted as 11. + /// isPair: two cards with same value (for splitting). + /// pairValue: the value of the pair (2-11). + /// cardCount: number of cards in hand (only double on 2 cards). + /// + private static string BasicStrategyAction(int playerTotal, bool isSoft, int dealerValue, + bool isPair, int pairValue, int cardCount) + { + bool canDouble = cardCount == 2; + + // ── Pair splitting (only on initial 2-card hand) ── + if (isPair && cardCount == 2) + { + if (pairValue == 11) // Aces + return "SPLIT"; + if (pairValue == 10) // 10s + return "STAND"; + if (pairValue == 9) // 9s: split vs 2-9 except 7 + return (dealerValue >= 2 && dealerValue <= 9 && dealerValue != 7) ? "SPLIT" : "STAND"; + if (pairValue == 8) // 8s + return "SPLIT"; + if (pairValue == 7) // 7s: split vs 2-7 + return (dealerValue >= 2 && dealerValue <= 7) ? "SPLIT" : "HIT"; + if (pairValue == 6) // 6s: split vs 2-6 + return (dealerValue >= 2 && dealerValue <= 6) ? "SPLIT" : "HIT"; + if (pairValue == 5) // 5s: double vs 2-9 + return (canDouble && dealerValue >= 2 && dealerValue <= 9) ? "DOUBLE" : "HIT"; + if (pairValue == 4) // 4s: split vs 5-6 + return (dealerValue == 5 || dealerValue == 6) ? "SPLIT" : "HIT"; + if (pairValue <= 3) // 2s, 3s: split vs 2-7 + return (dealerValue >= 2 && dealerValue <= 7) ? "SPLIT" : "HIT"; + } + + // ── Soft totals (Ace counted as 11) ── + if (isSoft) + { + if (playerTotal >= 20) return "STAND"; // Soft 20+ + if (playerTotal == 19) // Soft 19 (A,8) + return (canDouble && dealerValue == 6) ? "DOUBLE" : "STAND"; + if (playerTotal == 18) // Soft 18 (A,7) + { + if (canDouble && dealerValue >= 2 && dealerValue <= 6) return "DOUBLE"; + if (dealerValue >= 9) return "HIT"; + return "STAND"; + } + if (playerTotal == 17) // Soft 17 (A,6) + return (canDouble && dealerValue >= 3 && dealerValue <= 6) ? "DOUBLE" : "HIT"; + if (playerTotal == 16) // Soft 16 (A,5) + return (canDouble && dealerValue >= 4 && dealerValue <= 6) ? "DOUBLE" : "HIT"; + if (playerTotal == 15) // Soft 15 (A,4) + return (canDouble && dealerValue >= 4 && dealerValue <= 6) ? "DOUBLE" : "HIT"; + if (playerTotal == 14) // Soft 14 (A,3) + return (canDouble && dealerValue >= 5 && dealerValue <= 6) ? "DOUBLE" : "HIT"; + if (playerTotal == 13) // Soft 13 (A,2) + return (canDouble && dealerValue >= 5 && dealerValue <= 6) ? "DOUBLE" : "HIT"; + return "HIT"; // Soft 12 or less + } + + // ── Hard totals ── + if (playerTotal >= 17) return "STAND"; + if (playerTotal >= 13 && playerTotal <= 16) // 13-16: stand vs 2-6 + return (dealerValue >= 2 && dealerValue <= 6) ? "STAND" : "HIT"; + if (playerTotal == 12) // 12: stand vs 4-6 + return (dealerValue >= 4 && dealerValue <= 6) ? "STAND" : "HIT"; + if (playerTotal == 11) // 11: always double + return canDouble ? "DOUBLE" : "HIT"; + if (playerTotal == 10) // 10: double vs 2-9 + return (canDouble && dealerValue >= 2 && dealerValue <= 9) ? "DOUBLE" : "HIT"; + if (playerTotal == 9) // 9: double vs 3-6 + return (canDouble && dealerValue >= 3 && dealerValue <= 6) ? "DOUBLE" : "HIT"; + return "HIT"; // 8 or less + } + + // --------------------------------------------------------------- + // Card total calculation from hand views + // --------------------------------------------------------------- + private static int CardEnumToBlackjackValue(int enumValue) + { + if (enumValue == 12) return 11; + if (enumValue >= 8) return 10; + if (enumValue >= 0) return enumValue + 2; + return 0; + } + + private static int CardEnumToBlackjackValue(int enumValue, ref int aceCount) + { + if (enumValue == 12) { aceCount++; return 11; } + if (enumValue >= 8) return 10; + if (enumValue >= 0) return enumValue + 2; + return 0; + } + + private static int CalculateHandTotal(object blackjackView, int handId, out bool isSoft, out int[] cardEnums) + { + isSoft = false; + cardEnums = new int[0]; + try + { + var handViewsList = _getHandViews.Invoke(blackjackView, null); + if (handViewsList == null) { _log.LogWarning("m_HandViews is null"); return -1; } + + int viewCount = Convert.ToInt32(handViewsList.GetType().GetProperty("Count")?.GetValue(handViewsList)); + var getItem = handViewsList.GetType().GetMethod("get_Item"); + + for (int i = 0; i < viewCount; i++) + { + var handView = getItem?.Invoke(handViewsList, new object[] { i }); + if (handView == null) continue; + + if (Convert.ToInt32(_getHVHandId.Invoke(handView, null)) != handId) continue; + + var cardsList = _getHVCards.Invoke(handView, null); + if (cardsList == null) { _log.LogWarning($"m_Cards null for hand {handId}"); return -1; } + + int cardCount = Convert.ToInt32(cardsList.GetType().GetProperty("Count")?.GetValue(cardsList)); + var getCard = cardsList.GetType().GetMethod("get_Item"); + + cardEnums = new int[cardCount]; + int total = 0, aceCount = 0; + string cardDebug = ""; + + for (int j = 0; j < cardCount; j++) + { + var card = getCard?.Invoke(cardsList, new object[] { j }); + if (card == null) continue; + int enumValue = Convert.ToInt32(_getCardValue.Invoke(card, null)); + cardEnums[j] = enumValue; + cardDebug += (j > 0 ? "," : "") + enumValue; + total += CardEnumToBlackjackValue(enumValue, ref aceCount); + } + + while (total > 21 && aceCount > 0) { total -= 10; aceCount--; } + isSoft = aceCount > 0; + + _log.LogInfo($"Hand {handId}: cards=[{cardDebug}] total={total} soft={isSoft}"); + return total; + } + + _log.LogWarning($"No hand view found with handId={handId} (searched {viewCount} views)"); + return -1; + } + catch (Exception ex) { _log.LogError($"CalculateHandTotal: {ex}"); return -1; } + } + + // --------------------------------------------------------------- + // Diagnostic: dump any object's getters (guarded by DEBUG) + // --------------------------------------------------------------- + private static void DumpRoundData(object roundData) + { + if (!DEBUG) return; + if (!_pluginEnabled) return; // Check if plugin is enabled + + try + { + + _log.LogInfo("Dumping Function ----------------------------------"); + _log.LogInfo("available bets 2: " + _getBets); + _log.LogInfo("DealerHand: " + _getRoundDealerHand); + _log.LogInfo("Hand Values: " + _getHandValues); + _log.LogInfo("Player Chips: " + _getPlayerChips); + + var bindAll = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly; + + _log.LogInfo($"=== {roundData.GetType().Name} getters ==="); + foreach (var m in roundData.GetType().GetMethods(bindAll)) + { + if (!m.Name.StartsWith("get_") || m.GetParameters().Length != 0) continue; + try { _log.LogInfo($" {m.ReturnType.Name} {m.Name} = {m.Invoke(roundData, null)}"); } + catch { _log.LogInfo($" {m.ReturnType.Name} {m.Name} = "); } + } + DEBUG = false; // Only dump once per session to avoid log spam + _log.LogInfo("Dump complete ----------------------------------"); + } + catch (Exception ex) { _log.LogInfo($"DumpRoundData: {ex}"); } + } + + // --------------------------------------------------------------- + // Plugin control methods + // --------------------------------------------------------------- + + /// + /// Enable or disable the plugin at runtime. + /// + public void SetEnabled(bool enabled) + { + _pluginEnabled = enabled; + Log.LogInfo($"{PluginName} {(enabled ? "enabled" : "disabled")}"); + } + + /// + /// Check if the plugin is currently enabled. + /// + public bool IsEnabled() => _pluginEnabled; + + /// + /// Reset session statistics (rounds played and net profit). + /// + public void ResetSessionStats() + { + Log.LogInfo($"Resetting session stats. Previous: {_sessionRoundsPlayed} rounds, {_sessionNetProfit:+#;-#;0} net profit"); + _sessionNetProfit = 0; + _sessionRoundsPlayed = 0; + Log.LogInfo("Session stats reset."); + } + + /// + /// Get current session statistics. + /// + public (int rounds, long netProfit) GetSessionStats() + { + return (_sessionRoundsPlayed, _sessionNetProfit); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static Type FindType(string fullName) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + var t = asm.GetType(fullName); + if (t != null) return t; + } + string shortName = fullName.Contains('.') + ? fullName[(fullName.LastIndexOf('.') + 1)..] + : fullName; + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try { foreach (var t in asm.GetTypes()) if (t.Name == shortName) return t; } + catch { } + } + return null; + } + + private void PatchMethod(string typeName, string methodName, HarmonyMethod patch, bool isPostfix = false) + { + try + { + Type targetType = FindType(typeName); + if (targetType == null) { Log.LogWarning($" Type not found: {typeName}"); return; } + + var methods = targetType.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly); + + int patched = 0; + foreach (var method in methods) + { + if (method.Name != methodName) continue; + try + { + if (isPostfix) _harmony.Patch(method, postfix: patch); + else _harmony.Patch(method, prefix: patch); + patched++; + Log.LogInfo($" Patched ({(isPostfix ? "postfix" : "prefix")}): {typeName}.{methodName}"); + } + catch (Exception ex) { Log.LogWarning($" Failed to patch {typeName}.{methodName}: {ex.Message}"); } + } + + if (patched == 0) + Log.LogWarning($" No methods named '{methodName}' found on {typeName}"); + } + catch (Exception ex) { Log.LogError($" Error patching {typeName}.{methodName}: {ex}"); } + } +} diff --git a/AutoBlackjack/blackjack_strategy.md b/AutoBlackjack/blackjack_strategy.md new file mode 100644 index 0000000..1a0a9dc --- /dev/null +++ b/AutoBlackjack/blackjack_strategy.md @@ -0,0 +1,63 @@ +Strategy: + +Bet sizing (Hi-Lo count-based): + True Count ≤ 1: minimum bet + True Count 2: 2× minimum + True Count 3: 4× minimum + True Count 4: 6× minimum + True Count ≥ 5: maximum bet + +Card Counting (Hi-Lo): + Cards 2-6: +1 + Cards 7-9: 0 + Cards 10, J, Q, K, A: -1 + True Count = Running Count / Estimated Decks Remaining + Assumes 6-deck shoe. Running count persists across rounds. + All visible cards (all players + dealer) are scanned at decision time. + Dealer reveal is also counted (Hook 4) for next-round accuracy. + +Illustrious 18 Deviations (applied when True Count thresholds met): + 16 vs 10: STAND when TC ≥ 0 (basic says HIT) + 16 vs 9: STAND when TC ≥ 5 (basic says HIT) + 15 vs 10: STAND when TC ≥ 4 (basic says HIT) + 12 vs 3: STAND when TC ≥ 2 (basic says HIT) + 12 vs 2: STAND when TC ≥ 3 (basic says HIT) + 10 vs 10: DOUBLE when TC ≥ 4 (basic says HIT) + 10 vs A: DOUBLE when TC ≥ 4 (basic says HIT) + 9 vs 2: DOUBLE when TC ≥ 1 (basic says HIT) + 9 vs 7: DOUBLE when TC ≥ 3 (basic says HIT) + +Soft totals: A soft total is any hand that has an Ace as one of the first two cards, the ace counts as 11 to start. + + Soft 20 (A,9) always stands. + Soft 19 (A,8) doubles against dealer 6, otherwise stand. + Soft 18 (A,7) doubles against dealer 2 through 6, and hits against 9 through Ace, otherwise stand. + Soft 17 (A,6) doubles against dealer 3 through 6, otherwise hit. + Soft 16 (A,5) doubles against dealer 4 through 6, otherwise hit. + Soft 15 (A,4) doubles against dealer 4 through 6, otherwise hit. + Soft 14 (A,3) doubles against dealer 5 through 6, otherwise hit. + Soft 13 (A,2) doubles against dealer 5 through 6, otherwise hit. + +Hard totals: A hard total is any hand that does not start with an ace in it, or it has been dealt an ace that can only be counted as 1 instead of 11. + + 17 and up always stands. + 16 stands against dealer 2 through 6, otherwise hit. + 15 stands against dealer 2 through 6, otherwise hit. + 14 stands against dealer 2 through 6, otherwise hit. + 13 stands against dealer 2 through 6, otherwise hit. + 12 stands against dealer 4 through 6, otherwise hit. + 11 always doubles. + 10 doubles against dealer 2 through 9 otherwise hit. + 9 doubles against dealer 3 through 6 otherwise hit. + 8 always hits. + +Splits: + Aces : always split + 10 : always stand + 9 : split vs dealer 2-9 except 7, else stand + 8 : always split + 7 : split vs dealer 2-7, else hit + 6 : split vs dealer 2-6, else hit + 5 : double vs dealer 2-9, else hit + 4 : split vs dealer 5-6, else hit + 1-3 : split vs dealer 2-7, else hit \ No newline at end of file diff --git a/ClaimMissionInformation.txt b/ClaimMissionInformation.txt new file mode 100644 index 0000000..a35ca53 --- /dev/null +++ b/ClaimMissionInformation.txt @@ -0,0 +1,16 @@ +`YoudaGames.MonopolyPoker.MissionCollection.MissionClaimHandler` has a a method `MissionClaimHandler.ClaimMission(int, int)` + +`YoudaGames.MonopolyPoker.MissionCollection.MissionGroupType` has Enum mission types + +YoudaGames.MonopolyPoker.MissionCollection.MissionClaimResponseData: + +- MissionClaimResponseData.message string +- MissionClaimResponseData.Message string +- MissionClaimResponseData.missionId int +- MissionClaimResponseData.missionId int +- MissionClaimResponseData.rewards Il2CppSystem.Collections.Generic.List +- MissionClaimResponseData.Rewards Il2CppSystem.Collections.Generic.List +- MissionClaimResponseData.success bool +- MissionClaimResponseData.Success bool + +YoudaGames.MonopolyPoker.MissionCollection.MissionClaimHandler \ No newline at end of file diff --git a/Global_TODO.md b/Global_TODO.md new file mode 100644 index 0000000..fd6513a --- /dev/null +++ b/Global_TODO.md @@ -0,0 +1,13 @@ +Task list placeholders: + +- [ ] Split mission automation into separate files in a project subfolder +- [ ] Add mission discovery service placeholder +- [ ] Add mission claim executor placeholder +- [ ] Add mission claim cache placeholder +- [ ] Add mission eligibility rules placeholder +- [ ] Add free daily currency handler cleanup placeholder +- [ ] Review reflection helper reuse across handlers + +Notes: +- Replace placeholders with concrete implementation tasks. +- Keep tasks short and actionable. diff --git a/MonopolyPokerHelper/BankrupcyRefillHandler.cs b/MonopolyPokerHelper/BankrupcyRefillHandler.cs new file mode 100644 index 0000000..1347dd8 --- /dev/null +++ b/MonopolyPokerHelper/BankrupcyRefillHandler.cs @@ -0,0 +1,51 @@ +using System; +using System.Reflection; +using static MonopolyPokerHelper.LoggerHelper; + +namespace MonopolyPokerHelper +{ + // + // Currently only handles auto-clicking the bailout button when the bankruptcy popup appears + // but can be expanded in the future for doing it before the popup appears, or for other related features. + // + internal static class BankrupcyRefillHandler + { + internal static void Load() + { + var postfix = typeof(BankrupcyRefillHandler).GetMethod( + nameof(AutoClickBailout), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new Exception($"{MethodBase.GetCurrentMethod()?.Name} - BankrupcyRefillHandler.AutoClickBailout method not found."); + + var harmonyPostfix = new HarmonyLib.HarmonyMethod(postfix); + + // Hook into the popup showing up + Plugin.PatchMethod("YoudaGames.MonopolyPoker.Popups.BailoutPopup", "Show", harmonyPostfix, true); + LogInfo("Hooked into BailoutPopup.Show for auto-refill."); + } + + private static void AutoClickBailout(object __instance) + { + LogInfo("BailoutPopup appeared! Automatically clicking bailout..."); + + try + { + // Find and invoke the BailoutClicked method on the given instance + var clickMethod = __instance.GetType().GetMethod("BailoutClicked", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (clickMethod != null) + { + clickMethod.Invoke(__instance, null); + LogInfo("Successfully auto-clicked the Bailout button."); + } + else + { + LogError("BailoutClicked method could not be found via reflection."); + } + } + catch (Exception ex) + { + LogError($"Failed to click bailout: {ex.Message}"); + } + } + } +} diff --git a/MonopolyPokerHelper/BlackjackHandler.cs b/MonopolyPokerHelper/BlackjackHandler.cs new file mode 100644 index 0000000..70fd9b0 --- /dev/null +++ b/MonopolyPokerHelper/BlackjackHandler.cs @@ -0,0 +1,19 @@ +using BepInEx; +using BepInEx.Unity.IL2CPP; +using HarmonyLib; +using System; +using System.Reflection; + +using static MonopolyPokerHelper.LoggerHelper; + +/// +/// A Handler that plays Blackjack +/// + +namespace MonopolyPokerHelper +{ + internal class BlackjackHandler : FeatureHandler + { + + } +} diff --git a/MonopolyPokerHelper/BoardwalkHandler.cs b/MonopolyPokerHelper/BoardwalkHandler.cs new file mode 100644 index 0000000..dea85de --- /dev/null +++ b/MonopolyPokerHelper/BoardwalkHandler.cs @@ -0,0 +1,107 @@ +using System; +using System.Reflection; + +using static MonopolyPokerHelper.LoggerHelper; + +namespace MonopolyPokerHelper; + +/// +/// BepInEx 6 IL2CPP plugin that automatically claims Boardwalk free dice rolls in Monopoly Poker. +/// +/// - Boardwalk Roll — Dice roll whenever available, usually available every ~90 - 180 minutes +/// +/// Works by hooking SocialLayerManager.Update() and running periodic checks +/// via reflection (no compile-time game assembly references needed). +/// +internal static class BoardwalkHandler +{ + // Cooldown flags (avoid spamming the same action) + private static bool _boardwalkRolled; + private static float _boardwalkRetryTimer; + private const float BOARDWALK_RETRY_INTERVAL = 60f; // re-check boardwalk every 1 minute + + // BoardwalkManager cached reflection handles + private static Type _boardwalkMgrType; + private static PropertyInfo _boardwalkInstance; + private static PropertyInfo _canRollDice; + private static MethodInfo _doDiceRoll; + public static bool IsResolved { get; private set; } + + public static void ResolveReflection() + { + if (IsResolved) return; + try + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + _boardwalkMgrType = ReflectionHelper.FindType("BoardwalkManager"); + if (_boardwalkMgrType != null) + { + _boardwalkInstance = _boardwalkMgrType.GetProperty("Instance", all); + _canRollDice = _boardwalkMgrType.GetProperty("CanRollDice", all); + _doDiceRoll = _boardwalkMgrType.GetMethod("DoDiceRoll", all); + LogDebug($"BoardwalkManager: found={_boardwalkMgrType != null} " + + $"instance={_boardwalkInstance != null} " + + $"canRoll={_canRollDice != null} " + + $"doRoll={_doDiceRoll != null}"); + } + else + { + LogError("BoardwalkManager type not found!"); + throw new TypeLoadException( + "BoardwalkHandler failed to resolve required type 'BoardwalkManager'."); + } + + IsResolved = true; + LogInfo("Reflection resolution complete."); + } + catch (Exception ex) + { + LogError($"Failed to resolve BoardwalkManager reflection: {ex}"); + } + } + + public static void Update(float deltaTime) + { + if (!IsResolved) return; + + _boardwalkRetryTimer += deltaTime; + + if (_boardwalkRolled && _boardwalkRetryTimer < BOARDWALK_RETRY_INTERVAL) + return; + + TryBoardwalkRoll(); + } + + private static void TryBoardwalkRoll() + { + try + { + var bwInstance = _boardwalkInstance?.GetValue(null); + if (bwInstance == null) { Log.LogError("BoardwalkManager instance is null"); return; } + + var canRoll = _canRollDice?.GetValue(bwInstance); + if (canRoll == null || !Convert.ToBoolean(canRoll)) + { + LogDebug("Boardwalk dice roll not available yet."); + return; + } + + _doDiceRoll.Invoke(bwInstance, [false]); + _boardwalkRolled = true; + _boardwalkRetryTimer = 0f; + LogInfo(">>> Rolled Boardwalk dice!"); + } + catch (Exception ex) + { + LogError($"Error with Boardwalk roll: {ex.Message}"); + } + } + + public static void Reset() + { + _boardwalkRolled = false; + _boardwalkRetryTimer = 0f; + } +} \ No newline at end of file diff --git a/MonopolyPokerHelper/ClaimMissionHandler.cs b/MonopolyPokerHelper/ClaimMissionHandler.cs new file mode 100644 index 0000000..341f2c1 --- /dev/null +++ b/MonopolyPokerHelper/ClaimMissionHandler.cs @@ -0,0 +1,564 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; + +using static MonopolyPokerHelper.LoggerHelper; + +namespace MonopolyPokerHelper; + +internal static class ClaimMissionHandler +{ + // Main polling cadence for mission scanning. + private const float MISSION_CHECK_INTERVAL = 20f; + // Safety limits to avoid walking very large/cyclic object graphs. + private const int MAX_DISCOVERY_NODES = 2000; + private const int MAX_DISCOVERY_DEPTH = 8; + // After repeated update failures, pause before retrying. + private const int FAILURE_BACKOFF_THRESHOLD = 3; + private const float FAILURE_BACKOFF_SECONDS = 60f; + + private static float _timer; + private static float _backoffRemaining; + public static bool IsResolved { get; private set; } + private static int _consecutiveFailures; + + private static Type _missionClaimHandlerType; + private static Type _missionGroupType; + private static MethodInfo _claimMissionMethod; + private static object _missionClaimHandlerInstance; + + // Keeps track of successfully claimed missions across ticks. + private static readonly HashSet _claimedMissionKeys = []; + + // Reflection lookup cache to reduce repeated GetProperty/GetField calls. + private static readonly Dictionary<(Type Type, string Name), MemberInfo> _memberCache = []; + // Negative cache for members not found on a given type. + private static readonly HashSet<(Type Type, string Name)> _missingMembers = []; + + public static void ResolveReflection() + { + try + { + if (IsResolved) return; + + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + _missionClaimHandlerType = ReflectionHelper.FindType("YoudaGames.MonopolyPoker.MissionCollection.MissionClaimHandler") + ?? ReflectionHelper.FindType("MissionClaimHandler"); + + _missionGroupType = ReflectionHelper.FindType("YoudaGames.MonopolyPoker.MissionCollection.MissionGroupType") + ?? ReflectionHelper.FindType("MissionGroupType"); + + if (_missionClaimHandlerType == null) + { + LogWarn("ClaimMissionHandler type not found."); + return; + } + + _claimMissionMethod = _missionClaimHandlerType.GetMethod( + "ClaimMission", + all, + binder: null, + types: new[] { typeof(int), typeof(int) }, + modifiers: null); + + if (_claimMissionMethod == null) + { + LogWarn("ClaimMission(int,int) method not found."); + return; + } + + _missionClaimHandlerInstance = ReflectionHelper.GetSingletonInstance(_missionClaimHandlerType); + if (!_claimMissionMethod.IsStatic && _missionClaimHandlerInstance == null) + { + LogWarn("MissionClaimHandler instance not found for non-static ClaimMission."); + return; + } + + IsResolved = true; + LogInfo("ClaimMissionHandler reflection resolved."); + } + catch (Exception ex) + { + LogError($"Failed to resolve ClaimMissionHandler reflection: {ex.Message}"); + } + } + + public static void Update(float deltaTime) + { + if (!IsResolved) return; + + // Cooldown after consecutive failures to avoid hammering reflection/invoke paths. + if (_backoffRemaining > 0f) + { + _backoffRemaining = Math.Max(0f, _backoffRemaining - deltaTime); + if (_backoffRemaining > 0f) + return; + + LogInfo("ClaimMissionHandler retrying after backoff."); + } + + _timer += deltaTime; + if (_timer < MISSION_CHECK_INTERVAL) + return; + + _timer = 0f; + + try + { + var instance = _claimMissionMethod.IsStatic + ? null + : (_missionClaimHandlerInstance ?? ReflectionHelper.GetSingletonInstance(_missionClaimHandlerType)); + + if (!_claimMissionMethod.IsStatic && instance == null) + { + LogError("MissionClaimHandler instance is null."); + return; + } + + _missionClaimHandlerInstance = instance; + + var candidates = DiscoverClaimableMissions(instance); + // Avoid duplicate ClaimMission invokes for the same mission in the same scan. + var uniqueCandidates = new HashSet<(int Group, int Id)>(); + foreach (var mission in candidates) + { + if (!uniqueCandidates.Add(mission)) + continue; + + var key = $"{mission.Group}:{mission.Id}"; + if (_claimedMissionKeys.Contains(key)) + continue; + + object response; + try + { + response = _claimMissionMethod.Invoke(instance, new object[] { mission.Group, mission.Id }); + } + catch (TargetInvocationException ex) + { + LogError($"ClaimMission invoke failed for mission {mission.Id} in group {mission.Group}: {ex.Message}"); + continue; + } + catch (TargetException ex) + { + LogError($"ClaimMission target invalid for mission {mission.Id} in group {mission.Group}: {ex.Message}"); + continue; + } + catch (TargetParameterCountException ex) + { + LogError($"ClaimMission signature mismatch for mission {mission.Id} in group {mission.Group}: {ex.Message}"); + continue; + } + catch (MethodAccessException ex) + { + LogError($"ClaimMission access denied for mission {mission.Id} in group {mission.Group}: {ex.Message}"); + continue; + } + + if (WasClaimSuccessful(response)) + { + _claimedMissionKeys.Add(key); + LogInfo($">>> Claimed mission {mission.Id} in group {mission.Group}"); + } + } + + _consecutiveFailures = 0; + } + catch (Exception ex) + { + _consecutiveFailures++; + LogWarn($"ClaimMissionHandler update error ({_consecutiveFailures}): {ex.Message}"); + + if (_consecutiveFailures >= FAILURE_BACKOFF_THRESHOLD) + { + _consecutiveFailures = 0; + _backoffRemaining = FAILURE_BACKOFF_SECONDS; + LogWarn($"ClaimMissionHandler entering backoff for {FAILURE_BACKOFF_SECONDS} seconds after repeated errors."); + } + } + } + + private static List<(int Group, int Id)> DiscoverClaimableMissions(object root) + { + var results = new List<(int Group, int Id)>(); + if (root == null) + return results; + + // Breadth-first walk over reachable objects from the root mission handler. + var queue = new Queue<(object Node, int Depth)>(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var processedNodes = 0; + + queue.Enqueue((root, 0)); + + while (queue.Count > 0) + { + var (current, depth) = queue.Dequeue(); + if (current == null || !visited.Add(current)) + continue; + + processedNodes++; + if (processedNodes > MAX_DISCOVERY_NODES) + { + LogWarn($"Claim mission discovery reached node limit ({MAX_DISCOVERY_NODES})."); + break; + } + + if (TryExtractClaimableMission(current, out var mission)) + { + results.Add(mission); + } + + if (depth >= MAX_DISCOVERY_DEPTH) + continue; + + foreach (var child in GetChildObjects(current)) + { + if (child != null) + queue.Enqueue((child, depth + 1)); + } + } + + return results; + } + + private static bool TryExtractClaimableMission(object candidate, out (int Group, int Id) mission) + { + mission = default; + + if (!TryGetBool(candidate, new[] { "CanClaim", "canClaim", "Claimable", "claimable", "IsClaimable", "isClaimable" }, out var canClaim) || !canClaim) + return false; + + if (!TryGetInt(candidate, new[] { "MissionId", "missionId", "Id", "id" }, out var missionId)) + return false; + + if (!TryGetMissionGroup(candidate, out var missionGroup)) + return false; + + mission = (missionGroup, missionId); + return true; + } + + private static bool TryGetMissionGroup(object source, out int group) + { + group = 0; + + if (TryGetInt(source, new[] { "MissionGroupType", "missionGroupType", "GroupType", "groupType" }, out group)) + return true; + + if (_missionGroupType != null) + { + if (TryGetEnumAsInt(source, "MissionGroupType", out group) || + TryGetEnumAsInt(source, "missionGroupType", out group) || + TryGetEnumAsInt(source, "GroupType", out group) || + TryGetEnumAsInt(source, "groupType", out group)) + { + return true; + } + } + + return false; + } + + private static bool TryGetEnumAsInt(object source, string name, out int value) + { + value = 0; + var memberValue = GetMemberValue(source, name); + if (memberValue == null) + return false; + + var type = memberValue.GetType(); + if (!type.IsEnum) + return false; + + value = Convert.ToInt32(memberValue); + return true; + } + + private static bool TryGetInt(object source, string[] names, out int value) + { + value = 0; + + foreach (var name in names) + { + var memberValue = GetMemberValue(source, name); + if (memberValue == null) + continue; + + try + { + value = Convert.ToInt32(memberValue); + return true; + } + catch (InvalidCastException ex) + { + LogError($"Failed int conversion for member '{name}': {ex.Message}"); + } + catch (FormatException ex) + { + LogError($"Failed int conversion for member '{name}': {ex.Message}"); + } + catch (OverflowException ex) + { + LogError($"Failed int conversion for member '{name}': {ex.Message}"); + } + catch (NotSupportedException ex) + { + LogError($"Failed int conversion for member '{name}': {ex.Message}"); + } + } + + return false; + } + + private static bool TryGetBool(object source, string[] names, out bool value) + { + value = false; + + foreach (var name in names) + { + var memberValue = GetMemberValue(source, name); + if (memberValue == null) + continue; + + try + { + value = Convert.ToBoolean(memberValue); + return true; + } + catch (InvalidCastException ex) + { + LogError($"Failed bool conversion for member '{name}': {ex.Message}"); + } + catch (FormatException ex) + { + LogError($"Failed bool conversion for member '{name}': {ex.Message}"); + } + catch (NotSupportedException ex) + { + LogError($"Failed bool conversion for member '{name}': {ex.Message}"); + } + } + + return false; + } + + private static object GetMemberValue(object source, string name) + { + ArgumentNullException.ThrowIfNull(source); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); + + var type = source.GetType(); + var key = (type, name); + MemberInfo member; + + lock (_memberCache) + { + // Fast path: use cached member info when available. + if (_memberCache.TryGetValue(key, out member)) + return GetMemberValueFromMember(member, source, name); + + // Fast path: skip reflection when we've already confirmed the member is missing. + if (_missingMembers.Contains(key)) + return null; + } + + try + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + for (var current = type; current != null; current = current.BaseType) + { + var prop = current.GetProperty(name, all); + if (prop != null) + { + lock (_memberCache) + { + _memberCache[key] = prop; + } + + return GetMemberValueFromMember(prop, source, name); + } + + var field = current.GetField(name, all); + if (field != null) + { + lock (_memberCache) + { + _memberCache[key] = field; + } + + return GetMemberValueFromMember(field, source, name); + } + } + + lock (_memberCache) + { + _missingMembers.Add(key); + } + + return null; + } + catch (AmbiguousMatchException ex) + { + LogError($"Member lookup for '{name}' on type '{type.FullName}' is ambiguous: {ex.Message}"); + } + catch (NotSupportedException ex) + { + LogError($"Member lookup for '{name}' on type '{type.FullName}' is unsupported: {ex.Message}"); + } + + return null; + } + + private static object GetMemberValueFromMember(MemberInfo member, object source, string name) + { + try + { + if (member is PropertyInfo property) + return property.GetValue(source); + + if (member is FieldInfo field) + return field.GetValue(source); + } + catch (TargetInvocationException ex) + { + LogError($"Reading member '{name}' failed due to invocation error: {ex.Message}"); + } + catch (MethodAccessException ex) + { + LogError($"Reading member '{name}' failed due to access restriction: {ex.Message}"); + } + catch (FieldAccessException ex) + { + LogError($"Reading member '{name}' failed due to field access restriction: {ex.Message}"); + } + catch (TargetException ex) + { + LogError($"Reading member '{name}' failed due to invalid target: {ex.Message}"); + } + catch (NotSupportedException ex) + { + LogError($"Reading member '{name}' is unsupported: {ex.Message}"); + } + + return null; + } + + private static IEnumerable GetChildObjects(object source) + { + ArgumentNullException.ThrowIfNull(source); + + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance; + + var type = source.GetType(); + + foreach (var property in type.GetProperties(all)) + { + if (property.GetIndexParameters().Length > 0) + continue; + + object value; + try + { + value = property.GetValue(source); + } + catch (TargetInvocationException ex) + { + LogError($"Skipping child property '{property.Name}' due to invocation error: {ex.Message}"); + continue; + } + catch (MethodAccessException ex) + { + LogError($"Skipping child property '{property.Name}' due to access restriction: {ex.Message}"); + continue; + } + catch (NotSupportedException ex) + { + LogError($"Skipping child property '{property.Name}' due to unsupported operation: {ex.Message}"); + continue; + } + + foreach (var child in ExpandChildValue(value)) + yield return child; + } + + foreach (var field in type.GetFields(all)) + { + object value; + try + { + value = field.GetValue(source); + } + catch (FieldAccessException ex) + { + LogError($"Skipping child field '{field.Name}' due to access restriction: {ex.Message}"); + continue; + } + catch (NotSupportedException ex) + { + LogError($"Skipping child field '{field.Name}' due to unsupported operation: {ex.Message}"); + continue; + } + catch (TargetException ex) + { + LogError($"Skipping child field '{field.Name}' due to invalid target: {ex.Message}"); + continue; + } + + foreach (var child in ExpandChildValue(value)) + yield return child; + } + } + + private static IEnumerable ExpandChildValue(object value) + { + if (value == null) + yield break; + + if (value is string) + yield break; + + if (value is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (item != null) + yield return item; + } + + yield break; + } + + if (value.GetType().IsValueType) + yield break; + + yield return value; + } + + private static bool WasClaimSuccessful(object response) + { + if (response == null) + return false; + + if (TryGetBool(response, new[] { "Success", "success" }, out var success)) + return success; + + return false; + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static readonly ReferenceEqualityComparer Instance = new(); + + public new bool Equals(object x, object y) => ReferenceEquals(x, y); + + public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/MonopolyPokerHelper/DailyWheelHandler.cs b/MonopolyPokerHelper/DailyWheelHandler.cs new file mode 100644 index 0000000..d234948 --- /dev/null +++ b/MonopolyPokerHelper/DailyWheelHandler.cs @@ -0,0 +1,9 @@ +namespace MonopolyPokerHelper +{ + internal class DailyWheelHandler + { + // TODO: Implement DailyWheelHandler. + // It is claimable once a day. is a popup as well as a HUD element. + // Ideally auto-claim automatically and remove popup and UI elements related to it. + } +} diff --git a/MonopolyPokerHelper/DistractionHandler.cs b/MonopolyPokerHelper/DistractionHandler.cs new file mode 100644 index 0000000..1acea86 --- /dev/null +++ b/MonopolyPokerHelper/DistractionHandler.cs @@ -0,0 +1,83 @@ +using System; +using System.Reflection; +using static MonopolyPokerHelper.LoggerHelper; + +/// +/// Suppresses Distractions and Rolling Offers pop-ups. +/// +namespace MonopolyPokerHelper +{ + internal static class DistractionHandler + { + internal static void Load() + { + // Method Distractions + var targetMethods = new (string TypeName, string MethodName)[] + { + // // Has daily free packs on login. + //("YoudaGames.MonopolyPoker.Popups.RollingOffersPopup", "Show"), // Rolling Offers Popup (the actual UI) + //("YoudaGames.MonopolyPoker.RollingOffers.MPRollingOffersManager", "OpenPopup"), //Rolling Offers Manager (triggers the popup) + //("YoudaGames.MonopolyPoker.HUD.RollingOffersHUDSideButton", "OnClick"), // HUD Button that opens the Rolling Offers Popup + + // Vault related classes - blocking all methods to prevent any vault-related popups, events, or UI from functioning + ("YoudaGames.MonopolyPoker.UI.HUD.Buttons.VaultHUDButton", "Show"), + + // Game Boot popups + ("YoudaGames.MonopolyPoker.Popups.BuyOneOrAllPopup", "Show"), + ("YoudaGames.MonopolyPoker.Popups.DealPopup", "Show"), + ("YoudaGames.MonopolyPoker.Popups.LimitedOffersPopup", "Show"), + }; + + var blockPatch = typeof(DistractionHandler).GetMethod( + nameof(BlockPrefix), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new Exception($"{MethodBase.GetCurrentMethod()?.Name} - DistractionHandler.BlockPrefix method not found."); + + foreach (var (TypeName, MethodName) in targetMethods) + { + Plugin.PatchMethod(TypeName, MethodName, blockPatch); + LogInfo($"Blocked Type: {TypeName} Method: {MethodName}"); + } + + // GameObjects - for HUD elements that don't have a show method but are active by default + var gameObjects = new (string TypeName, string MethodName)[] + { + // BuyOneOrAll doesn't declare its own Awake, so we patch its base class + // ("YoudaGames.MonopolyPoker.UI.HUD.SideButtons.BuyOneOrAllHUDSideButton", "Awake"), Doesn't have an awake method, patching the base + ("YoudaGames.MonopolyPoker.UI.HUD.SideButtons.BaseLeftHUDSideButton", "Awake"), + ("YoudaGames.MonopolyPoker.UI.HUD.SideButtons.DealsHUDSideButton", "Awake"), + }; + + var disableObjectPatch = typeof(DistractionHandler).GetMethod( + nameof(DisableGameObjectPostfix), BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new Exception($"{MethodBase.GetCurrentMethod()?.Name} - DistractionHandler.DisableGameObjectPostfix method not found."); + + var disablePatchHarmony = new HarmonyLib.HarmonyMethod(disableObjectPatch); + foreach (var (TypeName, MethodName) in gameObjects) + { + Plugin.PatchMethod(TypeName, MethodName, disablePatchHarmony, isPostfix: true); + LogInfo($"Blocked GameObject: {TypeName}"); + } + } + + /// + /// Harmony prefix that blocks execution of the original method. + /// Returning false = skip original method body entirely. + /// Harmony redirects to here. + /// + private static bool BlockPrefix() => false; + + /// + /// Forces a MonoBehaviour's GameObject to instantly deactivate. + /// For HUD elements that don't have a show method but are active by default + /// + private static void DisableGameObjectPostfix(UnityEngine.Component __instance) + { + var name = __instance.GetType().Name; + if (name is "BuyOneOrAllHUDSideButton" or "DealsHUDSideButton") + { + __instance.gameObject.SetActive(false); + LogDebug($"Deactivated GameObject for: {name}"); + } + } + } +} diff --git a/MonopolyPokerHelper/FeatureHandler.cs b/MonopolyPokerHelper/FeatureHandler.cs new file mode 100644 index 0000000..164a3a2 --- /dev/null +++ b/MonopolyPokerHelper/FeatureHandler.cs @@ -0,0 +1,14 @@ +using System; + +namespace MonopolyPokerHelper +{ + /// + /// Base class for handlers to share properties like IsEnabled and IsResolved. + /// By using a generic type parameter (CRTP), each handler gets its own independent static state. + /// + internal abstract class FeatureHandler where T : FeatureHandler + { + public static bool IsEnabled { get; set; } = true; + public static bool IsResolved { get; protected set; } + } +} diff --git a/MonopolyPokerHelper/FreeDailyCurrencyHandler.cs b/MonopolyPokerHelper/FreeDailyCurrencyHandler.cs new file mode 100644 index 0000000..598134b --- /dev/null +++ b/MonopolyPokerHelper/FreeDailyCurrencyHandler.cs @@ -0,0 +1,131 @@ +using System; +using System.Reflection; + +using static MonopolyPokerHelper.LoggerHelper; + +namespace MonopolyPokerHelper; + +internal static class FreeDailyCurrencyHandler +{ + // FreeBonusManager + private static Type _freeBonusMgrType; + private static PropertyInfo _freeBonusInstance; + private static PropertyInfo _canClaimFreeBonus; + private static MethodInfo _collectBonus; + + public static bool IsResolved { get; private set; } + + public static void ResolveReflection() + { + try + { + if (IsResolved) + { + LogInfo("FreeDailyCurrencyHandler reflection already resolved."); + return; + } + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + _freeBonusMgrType = ReflectionHelper.FindType("FreeBonusManager"); + if (_freeBonusMgrType != null) + { + _freeBonusInstance = _freeBonusMgrType.GetProperty("Instance", all) + ?? _freeBonusMgrType.BaseType?.GetProperty("Instance", all); + _canClaimFreeBonus = _freeBonusMgrType.GetProperty("CanClaimFreeBonus", all); + _collectBonus = _freeBonusMgrType.GetMethod("CollectBonus", all); + + LogInfo($"FreeBonusManager: found={_freeBonusMgrType != null} " + + $"instance={_freeBonusInstance != null} " + + $"canClaim={_canClaimFreeBonus != null} " + + $"collect={_collectBonus != null}"); + + IsResolved = true; + } + else + { + LogError("FreeBonusManager type not found!"); + throw new TypeLoadException("FreeDailyCurrencyHandler failed to resolve required type 'FreeBonusManager'."); + } + } + catch (Exception ex) + { + LogError($"Failed to resolve FreeBonusManager reflection: {ex}"); + } + } + + public static void Update() + { + if (!IsResolved) return; + + TryCollectFreeBonus(0, "Chips"); + TryCollectFreeBonus(1, "M-Money"); + } + + private static void TryCollectFreeBonus(int channel, string label) + { + try + { + if (_freeBonusMgrType == null || _freeBonusInstance == null) + { + throw new InvalidOperationException($"FreeBonusManager type or instance property not found, cannot claim free {label}."); + } + + + var instance = _freeBonusInstance.GetValue(null); + if (instance == null) { LogError($"FreeBonusManager instance is null"); return; } + + var canClaim = _canClaimFreeBonus?.GetValue(instance); + if (canClaim == null) { LogError($"canClaimFreeBonus instance is null") ; return; } + + bool canClaimChannel; + if (canClaim is bool[] boolArr) + { + if (channel >= boolArr.Length) return; + canClaimChannel = boolArr[channel]; + } + else + { + var getMethod = canClaim.GetType().GetMethod("get_Item") + ?? canClaim.GetType().GetMethod("Get"); + if (getMethod != null) + { + var result = getMethod.Invoke(canClaim, new object[] { channel }); + canClaimChannel = Convert.ToBoolean(result); + } + else + { + var indexer = canClaim.GetType().GetProperty("Item"); + if (indexer != null) + { + canClaimChannel = Convert.ToBoolean(indexer.GetValue(canClaim, new object[] { channel })); + } + else + { + LogError($"Could not read CanClaimFreeBonus[{channel}]"); + return; + } + } + } + + if (!canClaimChannel) + { + LogDebug($"Free {label} not available (already claimed or on cooldown)."); + return; + } + + _collectBonus.Invoke(instance, new object[] { channel }); + LogInfo($">>> Claimed free {label} (channel {channel})!"); + } + catch (Exception ex) + { + LogError($"Error collecting free {label}: {ex.Message}"); + } + } + + public static void Reset() + { + //_chipsClaimed = false; + //_mMoneyClaimed = false; + } +} diff --git a/MonopolyPokerHelper/LoggerHelper.cs b/MonopolyPokerHelper/LoggerHelper.cs new file mode 100644 index 0000000..307067b --- /dev/null +++ b/MonopolyPokerHelper/LoggerHelper.cs @@ -0,0 +1,12 @@ +using BepInEx.Logging; + +namespace MonopolyPokerHelper; + +internal static class LoggerHelper +{ + internal static ManualLogSource Log { get; set; } + internal static void LogInfo(string message) => Log?.LogInfo(message); + internal static void LogWarn(string message) => Log?.LogWarning(message); + internal static void LogError(string message) => Log?.LogError(message); + internal static void LogDebug(string message) => Log?.LogDebug(message); +} \ No newline at end of file diff --git a/MonopolyPokerHelper/MonopolyPokerHelper.csproj b/MonopolyPokerHelper/MonopolyPokerHelper.csproj new file mode 100644 index 0000000..2c9169e --- /dev/null +++ b/MonopolyPokerHelper/MonopolyPokerHelper.csproj @@ -0,0 +1,53 @@ + + + + net6.0 + MonopolyPokerHelper + MonopolyPokerHelper + 1.0.0 + Unknown + true + latest + + https://api.nuget.org/v3/index.json; + https://nuget.bepinex.dev/v3/index.json; + https://nuget.samboy.dev/v3/index.json + + MonopolyPokerHelper + + + + C:\Program Files (x86)\Steam\steamapps\common\Monopoly Poker\BepInEx\core + C:\Program Files (x86)\Steam\steamapps\common\Monopoly Poker\BepInEx\interop + $(BepInExDir)\..\plugins\MonopolyPokerHelper + + + + + + + $(InteropDir)\UnityEngine.CoreModule.dll + false + + + $(InteropDir)\Il2Cppmscorlib.dll + false + + + $(InteropDir)\Il2CppInterop.Runtime.dll + false + + + + + + + + + + + + + + + diff --git a/MonopolyPokerHelper/Plugin.cs b/MonopolyPokerHelper/Plugin.cs new file mode 100644 index 0000000..f6e7db0 --- /dev/null +++ b/MonopolyPokerHelper/Plugin.cs @@ -0,0 +1,219 @@ +using BepInEx; +using BepInEx.Unity.IL2CPP; +using HarmonyLib; +using System; +using System.Reflection; +using static MonopolyPokerHelper.LoggerHelper; + +namespace MonopolyPokerHelper +{ + /// + /// Main BepInEx plugin entry point for Monopoly Poker helper features. + /// Sets up Harmony patches and periodically runs feature handlers. + /// + [BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)] + public class Plugin : BasePlugin + { + private static Harmony _harmony; + + // Accumulated time since last handler run. + private static float _elapsed; + // How often to run handlers after startup, in seconds. + private const float CHECK_INTERVAL = 0.25f; + // Initial delay before any logic runs, in seconds. + private const float INITIAL_DELAY = 15f; + // Ensures the initial delay logic only runs once. + private static bool _initialDelayDone; + // Ensures reflection is resolved successfully before handlers execute. + public static bool IsResolved { get; private set; } + public static bool _pluginDisabled; + /// + /// BepInEx entry point. Sets up Harmony patches and logs plugin startup. + /// + public override void Load() + { + LoggerHelper.Log = base.Log; + _harmony = new Harmony(MyPluginInfo.PLUGIN_GUID); + + // Redundant, BepInEx does this automatically + //LogInfo($"{MyPluginInfo.PLUGIN_NAME} v{MyPluginInfo.PLUGIN_VERSION} loading..."); + + var updatePatch = new HarmonyMethod(typeof(Plugin).GetMethod( + nameof(UpdatePostfix), BindingFlags.Static | BindingFlags.NonPublic)); + PatchMethod("SocialLayerManager", "Update", updatePatch, isPostfix: true); + + DistractionHandler.Load(); + BankrupcyRefillHandler.Load(); + + LogInfo($"Plugin {MyPluginInfo.PLUGIN_GUID} is loaded!"); + + } + /// + /// Resolves all reflection-based handles used by handlers. + /// Must succeed before periodic updates start running. + /// + private static bool ResolveReflection() + { + if (IsResolved) return true; + LogInfo("Resolving reflection handles..."); + + BoardwalkHandler.ResolveReflection(); + FreeDailyCurrencyHandler.ResolveReflection(); + // ClaimMissionHandler.ResolveReflection(); + + IsResolved = BoardwalkHandler.IsResolved && + FreeDailyCurrencyHandler.IsResolved + // ClaimMissionHandler.IsResolved + ; + if (IsResolved) { LogInfo("Reflection handles for Monopoly Poker helper resolved successfully."); } + return IsResolved; + } + + /// + /// Harmony postfix for SocialLayerManager.Update. + /// Throttles execution, enforces an initial delay, + /// ensures reflection is ready, then invokes feature handlers. + /// + private static void UpdatePostfix() + { + if (_pluginDisabled) return; + + float dt = UnityEngine.Time.deltaTime; + + if (!_initialDelayDone) + { + _elapsed += dt; + if (_elapsed < INITIAL_DELAY) return; + _initialDelayDone = true; + _elapsed = CHECK_INTERVAL; + LogInfo("Initial start delay complete"); + } + + if (!ResolveReflection()) + { + DisablePlugin("Failed to resolve reflection handles"); + return; + } + + _elapsed += dt; + + if (_elapsed < CHECK_INTERVAL) return; + _elapsed = 0f; + + try + { + //LogInfo("Running periodic handlers..."); + SafeInvoke(() => BoardwalkHandler.Update(dt), nameof(BoardwalkHandler.Update)); + SafeInvoke(FreeDailyCurrencyHandler.Update, nameof(FreeDailyCurrencyHandler.Update)); + // SafeInvoke(() => ClaimMissionHandler.Update(dt), nameof(ClaimMissionHandler.Update)); + } + catch (Exception ex) + { + LogError($"UpdatePostfix error: {ex.Message}"); + } + } + + /// + /// Safely invokes a handler action and logs any exceptions with the given name. + /// Use when you want individual handler failures not to affect others. + /// + private static void SafeInvoke(Action action, string name) + { + try { action(); } + catch (Exception ex) { LogError($"Error invoking handler {name}: {ex}"); } + } + + /// + /// Finds a target type and patches all methods with the given name + /// using the provided HarmonyMethod as prefix or postfix. + /// + /// Name of the target type to search for. + /// Name of the methods to patch on that type. + /// Harmony patch method (prefix or postfix). + /// True to apply as postfix, false for prefix. + public static bool PatchMethod( + string typeName, + string methodName, + HarmonyMethod patch, + bool isPostfix = false, + Type[] parameterTypes = null) + { + Type targetType = ReflectionHelper.FindType(typeName); + if (targetType == null) + { + LogError($" Type not found: {typeName}"); + return false; + } + + int patched = 0; + if (parameterTypes != null) + { + var method = targetType.GetMethod( + methodName, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly, + null, + parameterTypes, + null); + if (method == null) + { + LogError($"No method named '{methodName}' with specified parameters found on {typeName}"); + return false; + } + if (isPostfix) _harmony.Patch(method, postfix: patch); + else _harmony.Patch(method, prefix: patch); + patched = 1; + } + else + { + var methods = targetType.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly); + + foreach (var method in methods) + { + if (method.Name != methodName) continue; + if (isPostfix) _harmony.Patch(method, postfix: patch); + else _harmony.Patch(method, prefix: patch); + + patched++; + } + } + + if (patched == 0) + { + LogError($"No methods named '{methodName}' found on {typeName}"); + return false; + } + + LogInfo($"Patched {patched} method(s): {typeName}.{methodName}"); + return true; + } + + public static void PatchMethod(string typeName, string methodName, MethodInfo patchMethod, bool isPostfix = false) + { + if (patchMethod == null) + { + LogWarn($"Patch method is null for {typeName}.{methodName}"); + return; + } + + PatchMethod(typeName, methodName, new HarmonyMethod(patchMethod), isPostfix); + } + private static void DisablePlugin(string reason) + { + if (_pluginDisabled) return; + + _pluginDisabled = true; + LogError($"Disabling plugin: {reason}"); + try + { + _harmony?.UnpatchSelf(); + } + catch (Exception e) + { + LogError($"Error during plugin unpatching: {e}"); + } + } + } +} \ No newline at end of file diff --git a/MonopolyPokerHelper/ReflectionHelper.cs b/MonopolyPokerHelper/ReflectionHelper.cs new file mode 100644 index 0000000..658374f --- /dev/null +++ b/MonopolyPokerHelper/ReflectionHelper.cs @@ -0,0 +1,170 @@ +using System; +using System.Reflection; +using static MonopolyPokerHelper.LoggerHelper; + +namespace MonopolyPokerHelper; + +internal static class ReflectionHelper +{ + // Common binding flags for reflection (shared across helpers) + // Unsure if this is a good idea as it's possible some helpers might want to use different flags + + // TODO: Decide if we want to keep AllBindingFlags + // It might be more flexible to not have a shared constant if different helpers have different needs, but it also might be nice to have a common set of flags for consistency. For now, I'll keep it as a constant for convenience, but we can always change it later if needed. + public const BindingFlags AllBindingFlags = + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + private static readonly string[] SingletonPropertyNames = ["Instance", "instance"]; + private static readonly string[] SingletonFieldNames = ["Instance", "instance", "_instance", "s_instance"]; + + /// + /// Searches all loaded assemblies for a type with the specified name and returns the corresponding Type object + /// if found. + /// + /// The search first attempts to find an exact match using the fully qualified name. If + /// no match is found, it searches for a type with a matching simple name. Only assemblies currently loaded in + /// the application domain are searched. + /// The fully qualified or simple name of the type to locate. This value is case-sensitive. + /// A Type object representing the type with the specified name, or null if the type cannot be found in any + /// loaded assembly. + public static Type FindType(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)); + + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + // Try exact match first + foreach (var asm in assemblies) + { + var t = asm.GetType(name); + if (t != null) + { + LogDebug(name + ": exact match found in " + asm.FullName); + return t; + } + } + + // Try partial match (unqualified name) + foreach (var asm in assemblies) + { + try + { + foreach (var t in asm.GetTypes()) + { + if (t.Name == name || t.FullName == name) + return t; + } + } + catch (ReflectionTypeLoadException ex) + { + LogDebug($"{name}: partial type load failure in {asm.FullName}: {ex.Message}"); + + foreach (var t in ex.Types) + { + if (t == null) + continue; + + if (t.Name == name || t.FullName == name) + { + LogDebug(name + ": partial match found in " + asm.FullName); + return t; + } + } + } + catch (NotSupportedException ex) + { + LogDebug($"{name}: unsupported reflection operation for {asm.FullName}: {ex.Message}"); + } + } + LogError(name + ": type not found in any loaded assembly!"); + return null; + } + + /// + /// Retrieves the singleton instance of the specified type, if available. + /// + /// This method searches for a static property or field commonly named 'Instance', + /// 'instance', '_instance', or 's_instance' on the specified type or its base types. If no such member is found + /// or the value is null, the method returns null. + /// The type to search for a singleton instance. + /// The singleton instance of the specified type if found; otherwise, null. + public static object GetSingletonInstance(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + foreach (var propertyName in SingletonPropertyNames) + { + try + { + for (var current = type; current != null; current = current.BaseType) + { + var prop = current.GetProperty(propertyName, all); + if (prop == null || prop.GetMethod == null || !prop.GetMethod.IsStatic) + continue; + + var value = prop.GetValue(null); + if (value == null) + continue; + + LogDebug($"Found singleton instance via property '{propertyName}' on type '{type.FullName}'"); + return value; + } + } + catch (AmbiguousMatchException ex) + { + LogDebug($"Skipping property '{propertyName}' on type '{type.FullName}' due to ambiguous match: {ex.Message}"); + } + catch (TargetInvocationException ex) + { + LogDebug($"Skipping property '{propertyName}' on type '{type.FullName}' due to invocation error: {ex.Message}"); + } + catch (MethodAccessException ex) + { + LogDebug($"Skipping property '{propertyName}' on type '{type.FullName}' due to access restriction: {ex.Message}"); + } + catch (NotSupportedException ex) + { + LogDebug($"Skipping property '{propertyName}' on type '{type.FullName}' due to unsupported reflection operation: {ex.Message}"); + } + } + + foreach (var fieldName in SingletonFieldNames) + { + try + { + for (var current = type; current != null; current = current.BaseType) + { + var field = current.GetField(fieldName, all); + if (field == null || !field.IsStatic) + continue; + + var value = field.GetValue(null); + if (value == null) + continue; + + LogDebug($"Found singleton instance via field '{fieldName}' on type '{type.FullName}'"); + return value; + } + } + catch (AmbiguousMatchException ex) + { + LogDebug($"Skipping field '{fieldName}' on type '{type.FullName}' due to ambiguous match: {ex.Message}"); + } + catch (FieldAccessException ex) + { + LogDebug($"Skipping field '{fieldName}' on type '{type.FullName}' due to access restriction: {ex.Message}"); + } + catch (NotSupportedException ex) + { + LogDebug($"Skipping field '{fieldName}' on type '{type.FullName}' due to unsupported reflection operation: {ex.Message}"); + } + } + + LogError($"Failed to find singleton instance for type '{type.FullName}'"); + return null; + } +} \ No newline at end of file diff --git a/MonopolyPokerMod.sln b/MonopolyPokerMod.sln new file mode 100644 index 0000000..ed145d3 --- /dev/null +++ b/MonopolyPokerMod.sln @@ -0,0 +1,80 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.2.11430.68 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoBlackjack", "AutoBlackjack\AutoBlackjack.csproj", "{B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C723F55D-2FC3-4D37-BFA0-A17FA0E0A0BF}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonopolyPokerHelper", "MonopolyPokerHelper\MonopolyPokerHelper.csproj", "{3175547E-8811-4E55-9249-FBB13297CC7E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Assembly-CSharp", "Assembly-CSharp\Assembly-CSharp.csproj", "{DAF50183-FF42-4926-ADB9-772EFA521BC4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Logdumps", "Logdumps", "{1E0CFDFA-7799-4D92-9C33-6871F67EB219}" + ProjectSection(SolutionItems) = preProject + ..\BlackjackTypeDump.log = ..\BlackjackTypeDump.log + Boardwalkpopup.log = Boardwalkpopup.log + ClaimMissionInformation.txt = ClaimMissionInformation.txt + MonopolyPokerHelper_2026_03_22.log = MonopolyPokerHelper_2026_03_22.log + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x64.Build.0 = Debug|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Debug|x86.Build.0 = Debug|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Release|x64.ActiveCfg = Release|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Release|x64.Build.0 = Release|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Release|x86.ActiveCfg = Release|Any CPU + {B1C2D3E4-5F6A-7B8C-9D0E-1F2A3B4C5D6E}.Release|x86.Build.0 = Release|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Debug|x64.ActiveCfg = Debug|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Debug|x64.Build.0 = Debug|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Debug|x86.Build.0 = Debug|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Release|Any CPU.Build.0 = Release|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Release|x64.ActiveCfg = Release|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Release|x64.Build.0 = Release|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Release|x86.ActiveCfg = Release|Any CPU + {3175547E-8811-4E55-9249-FBB13297CC7E}.Release|x86.Build.0 = Release|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Debug|x64.ActiveCfg = Debug|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Debug|x64.Build.0 = Debug|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Debug|x86.ActiveCfg = Debug|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Debug|x86.Build.0 = Debug|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Release|Any CPU.Build.0 = Release|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Release|x64.ActiveCfg = Release|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Release|x64.Build.0 = Release|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Release|x86.ActiveCfg = Release|Any CPU + {DAF50183-FF42-4926-ADB9-772EFA521BC4}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1E0CFDFA-7799-4D92-9C33-6871F67EB219} = {C723F55D-2FC3-4D37-BFA0-A17FA0E0A0BF} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7F85BEED-1562-48D3-AF3C-97E6D84501D3} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..36e4cff --- /dev/null +++ b/README.md @@ -0,0 +1,306 @@ +# Monopoly Poker Mod Helper + +This repository contains helper code, notes, and logs for modding `Monopoly Poker`. + +## Overview + +`Monopoly Poker` is an Unity IL2CPP game on Steam, so mods should be built for `IL2CPP`. BepInEx IL2CPP is used in this project. +This repo is focused on reverse engineering game behavior, finding useful types through reflection, and automating helper actions. + +## Requirements + +- Visual Studio +- `.NET 6` +- `BepInEx IL2CPP` +- `dnSpy` (for inspecting game assemblies and objects) + +## Repository Structure + +### `MonopolyPokerHelper/` +Main mod/helper project code. + +Example files: +- `ClaimMissionHandler.cs` - discovers and claims missions through reflection + +### Assembly-CSharp + +This is a dnSpy dump of the game's main assembly, used for reference and exploration. It is not meant to be built or modified directly. + +## What This Project Is For + +- Uses reflection to inspect game objects and types +- Automates helper actions inside the game +- Stores notes and logs for reverse engineering work + +## Notes + +- The dash character `—` may display incorrectly in the BepInEx console. +- `dnSpy` may need a fix for loading certain assemblies. +- You can use `BepInEx.ConfigurationManager` if you want an in-game configuration UI. + +## Setup + +1. Open the solution in Visual Studio. +2. Ensure the project targets `.NET 6` and references the game assembly as needed. +3. Build the helper project. +4. Deploy the output into the BepInEx plugin folder. + +## Useful References + +- BepInEx IL2CPP: https://github.com/BepInEx/BepInEx +- Configuration Manager: https://github.com/BepInEx/BepInEx.ConfigurationManager/releases/tag/v18.4.1 +- [UnityExplorer](https://github.com/sinai-dev/UnityExplorer) (for browsing game assets and types during runtime) +- **BepInEx Docs**: +- **Harmony Docs**: + +Note UnityExplorer is archived, need to use a fork. + +## Known Issues + +### dnSpy Import Project + +When trying to load a disassembled project that was then reassembled with dnSpy into Visual Studio you'll encounter the error: + +`A numeric comparison was attempted on "$(_TargetFrameworkVersionWithoutV)" that evaluates to "" instead of a number, in condition "('$(TargetFrameworkIdentifier)' != '.NETCoreApp'` + +This is because the project file generated by dnSpy is adding a v to and as well as not including _TargetFrameworkVersionWithoutV. To fix this: + +1. Open the .csproj file in a text editor. +2. Near the top you'll see these two lines: +```xml +v5.0 +.NETCoreApp +``` + +Modify the first line (remove the "v"), and insert the second line, so it looks like this: + +```xml +5.0 +<_TargetFrameworkVersionWithoutV>5.0 +.NETCoreApp +``` + +Load the project as you were, and it should work. + +Mileage may vary with different versions of Visual Studio and .NET SDK, but this is the general fix for the issue. + +[Source](https://www.clearhat.org/post/how-fix-numeric-comparison-was-attempted-_targetframeworkversionwithoutv-evaluates-instead-number-condition-targetframeworkidentifier-netcoreapp) + +### Encoding issues in BepInEx Console + +"—" always shows as ΓòÉ on BepInEx Console. Use the regular hyphen "-" instead. + +## How to Extend This README + +Add new sections when needed: +- setup and build instructions +- file-by-file descriptions +- known issues +- update history + +## Git Stash Reminder + +to git stash staged changes, you can use the following command: + +``` +git stash push --staged -m "message" +``` + +## Modding Guide + +### Tech Stack + +#### What is BepInEx? + +**BepInEx** (Bepis Injector Extendable) is a plugin/mod loader for Unity games. It: + +- Injects itself into the game when it launches +- Loads your plugin DLLs automatically from `BepInEx/plugins/` +- Provides logging, configuration, and modding APIs +- Handles IL2CPP games (compiled to native C++ code, harder to mod than Mono) + +#### What is Harmony? + +**Harmony** is a runtime patching library that lets you modify game methods without changing the original game files: + +- **Prefix**: Run your code BEFORE the original method (can block the original) +- **Postfix**: Run your code AFTER the original method +- **Transpiler**: Modify the method's IL code directly (advanced) + +You patch methods by their type and method name - no compile-time references needed! + +--- + +## 📋 Common Patterns + +### Pattern: Periodic Timer + +```csharp +private static float _timer; +private const float INTERVAL = 10f; + +private static void UpdatePostfix() +{ + _timer += UnityEngine.Time.deltaTime; + if (_timer < INTERVAL) return; + _timer = 0f; + + // Do something every 10 seconds +} +``` + +### Pattern: Singleton Access + +```csharp +// Most game managers use the Singleton pattern +var instance = ManagerType.GetProperty("Instance").GetValue(null); +``` + +### Pattern: Safe Reflection + +```csharp +try +{ + var method = type.GetMethod("MethodName"); + if (method == null) + { + Log.LogWarning("Method not found!"); + return; + } + method.Invoke(instance, args); +} +catch (Exception ex) +{ + Log.LogError($"Reflection error: {ex.Message}"); +} +``` + +### Pattern: Patch All Overloads + +```csharp +var methods = targetType.GetMethods(BindingFlags.Public | BindingFlags.Instance); +foreach (var method in methods) +{ + if (method.Name == "TargetMethod") + { + _harmony.Patch(method, prefix: myPrefix); + } +} +``` + +--- + +## ⚠️ Common Pitfalls + +### 1. **IL2CPP Array Indexing** + +IL2CPP arrays need special handling: + +```csharp +// DON'T: arr[0] +// DO: +var getMethod = array.GetType().GetMethod("get_Item"); +var value = getMethod.Invoke(array, new object[] { 0 }); +``` + +### 2. **Timing Issues** + +Game managers may not be loaded immediately: + +```csharp +// Wait before trying to access managers +if (!_initialDelayDone) +{ + _elapsed += deltaTime; + if (_elapsed < 30f) return; // wait 30 seconds + _initialDelayDone = true; +} +``` + +### 3. **Type Name Collisions** + +Multiple assemblies may have classes with the same name: + +```csharp +// Use full namespace +FindType("YoudaGames.MonopolyPoker.Popups.RollingOffersPopup"); +``` + +### 4. **Harmony Patch Failures** + +Methods might not exist or load later: + +```csharp +try +{ + _harmony.Patch(method, prefix); +} +catch (Exception ex) +{ + Log.LogWarning($"Patch failed: {ex.Message}"); + // Method may load later - try again or check name +} +``` + +--- + +## 🎉 Example Mod Ideas + +Now that you understand the basics, try creating: + +1. **Auto Claimer** - Automatically collect daily rewards (you have this!) +2. **Speed Hack** - Patch animation methods to speed up gameplay +3. **UI Remover** - Block annoying popups (you have this!) +4. **Unlimited Resources** - Modify currency values via reflection +5. **Auto Player** - Hook game actions and make decisions automatically +6. **Visual Tweaks** - Modify Unity UI elements at runtime + +--- + +## 🐛 Debugging Tips + +### View Logs + +``` +/BepInEx/LogOutput.log +``` + +### Enable Debug Logging + +In your plugin: + +```csharp +Log.LogDebug("Debug message"); // Will show if debug level enabled +``` + +In `BepInEx/config/BepInEx.cfg`: + +```ini +[Logging.Console] +LogLevels = Fatal, Error, Warning, Message, Info, Debug +``` + + +## UnityExplorer Console Snippets + +### Dumping GameObject Information + +Replace the type with the one you need, e.g., `YoudaGames.MonopolyPoker.Popups.BoardwalkJackpotRollPopup`. + +``` +var instances = UnityEngine.Object.FindObjectsOfType(); + +foreach (var popup in instances) +{ + Console.WriteLine($"--- Begin for instance {popup} ---"); + var type = popup.GetType(); + foreach (var prop in type.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + Console.WriteLine($"{prop.Name}: {prop.GetValue(popup)}"); + } + Console.WriteLine("--- End ---"); +} +``` + + +TODO: Perhaps seperate this README into a docs folder and link to it from the main README, to keep the main README concise and focused on project overview and setup. \ No newline at end of file