From c5bd6eea06328dfed810e3a4e72a405c25c9d7ef Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 15 Aug 2026 02:34:40 -0700 Subject: [PATCH 1/2] feat(casino): expose game state and lifecycle events --- S1API.Tests/Casino/CasinoApiContractTests.cs | 166 +++++++ S1API/Casino/CasinoGameRegistry.cs | 282 +++++++++++ S1API/Casino/CasinoGames.cs | 440 ++++++++++++++++++ S1API/Casino/CasinoSnapshots.cs | 272 +++++++++++ S1API/Casino/SlotMachineHelper.cs | 28 +- .../ActionSpecs/UseSlotMachineSpec.cs | 10 +- S1API/Internal/Patches/CasinoGamePatches.cs | 128 +++++ S1API/docs/casino-games.md | 116 +++++ S1API/docs/casino-slot-machines.md | 4 + S1API/docs/toc.yml | 4 +- 10 files changed, 1441 insertions(+), 9 deletions(-) create mode 100644 S1API.Tests/Casino/CasinoApiContractTests.cs create mode 100644 S1API/Casino/CasinoGameRegistry.cs create mode 100644 S1API/Casino/CasinoGames.cs create mode 100644 S1API/Casino/CasinoSnapshots.cs create mode 100644 S1API/Internal/Patches/CasinoGamePatches.cs create mode 100644 S1API/docs/casino-games.md diff --git a/S1API.Tests/Casino/CasinoApiContractTests.cs b/S1API.Tests/Casino/CasinoApiContractTests.cs new file mode 100644 index 00000000..82818436 --- /dev/null +++ b/S1API.Tests/Casino/CasinoApiContractTests.cs @@ -0,0 +1,166 @@ +using System.Reflection; +using S1API.Casino; + +#if IL2CPPMELON +using S1Casino = Il2CppScheduleOne.Casino; +#elif MONOMELON +using S1Casino = ScheduleOne.Casino; +#endif + +namespace S1API.Tests.Casino; + +public sealed class CasinoApiContractTests +{ + [Fact] + public void NativeLifecyclePatchPointsExistInTargetRuntime() + { + Assert.NotNull(typeof(S1Casino.BlackjackGameController).GetMethod( + "set_CurrentStage", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); + Assert.NotNull(typeof(S1Casino.RTBGameController).GetMethod( + "set_CurrentStage", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); + Assert.NotNull(typeof(S1Casino.SlotMachine).GetMethod( + "RpcLogic___StartSpin_2659526290", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); + Assert.NotNull(typeof(S1Casino.SlotMachine).GetMethod( + "DisplayOutcome", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); + } + + [Fact] + public void ManagedEnumsPreserveNativeWireValues() + { + Assert.Equal((int)S1Casino.BlackjackGameController.EStage.WaitingForPlayers, (int)BlackjackStage.WaitingForPlayers); + Assert.Equal((int)S1Casino.BlackjackGameController.EStage.Ending, (int)BlackjackStage.Ending); + Assert.Equal((int)S1Casino.RTBGameController.EStage.RedOrBlack, (int)RideTheBusStage.RedOrBlack); + Assert.Equal((int)S1Casino.RTBGameController.EStage.Suit, (int)RideTheBusStage.Suit); + Assert.Equal((int)S1Casino.PlayingCard.ECardSuit.Clubs, (int)CasinoCardSuit.Clubs); + Assert.Equal((int)S1Casino.PlayingCard.ECardValue.King, (int)CasinoCardValue.King); + Assert.Equal((int)S1Casino.SlotMachine.ESymbol.Seven, (int)SlotSymbol.Seven); + Assert.Equal((int)S1Casino.SlotMachine.EOutcome.NoWin, (int)SlotOutcome.NoWin); + } + + [Theory] + [InlineData(typeof(CasinoPlayerSnapshot))] + [InlineData(typeof(SlotSpinSnapshot))] + public void SnapshotReferenceTypesExposeNoPublicSetters(Type snapshotType) + { + Assert.All( + snapshotType.GetProperties(BindingFlags.Public | BindingFlags.Instance), + property => Assert.Null(property.SetMethod)); + Assert.Empty(snapshotType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)); + } + + [Fact] + public void CasinoWrappersCannotBePubliclyConstructedOrMutated() + { + Type[] wrapperTypes = + { + typeof(BlackjackGame), + typeof(RideTheBusGame), + typeof(SlotMachine) + }; + + foreach (Type wrapperType in wrapperTypes) + { + Assert.Empty(wrapperType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)); + Assert.All( + wrapperType.GetProperties(BindingFlags.Public | BindingFlags.Instance), + property => Assert.Null(property.SetMethod)); + } + } + + [Fact] + public void RegistrySurfaceIsReadOnlyDiscoveryAndQueriesOnly() + { + MethodInfo[] publicMethods = typeof(CasinoGameRegistry) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(method => !method.IsSpecialName) + .ToArray(); + + Assert.NotEmpty(publicMethods); + Assert.All(publicMethods, method => + Assert.True( + method.Name.StartsWith("Get", StringComparison.Ordinal) || + method.Name.StartsWith("Find", StringComparison.Ordinal), + $"Unexpected registry method: {method.Name}")); + Assert.DoesNotContain(publicMethods, method => method.ReturnType == typeof(void)); + } + + [Fact] + public void LegacyNativeSlotLookupRemainsAsAnObsoleteCompatibilityShim() + { + MethodInfo method = typeof(SlotMachineHelper).GetMethod( + nameof(SlotMachineHelper.FindNearestSlotMachine), + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(UnityEngine.Vector3), typeof(float) }, + modifiers: null)!; + + Assert.NotNull(method); + Assert.NotNull(method.GetCustomAttribute()); + Assert.Equal(typeof(S1Casino.SlotMachine), method.ReturnType); + + MethodInfo managedMethod = typeof(CasinoGameRegistry).GetMethod( + nameof(CasinoGameRegistry.FindNearestSlotMachine), + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(UnityEngine.Vector3), typeof(float) }, + modifiers: null)!; + + Assert.NotNull(managedMethod); + Assert.Equal(typeof(SlotMachine), managedMethod.ReturnType); + } + + [Fact] + public void PublicCasinoApiDoesNotExposeNativeCasinoTypes() + { + Type[] publicCasinoTypes = + { + typeof(CasinoGameRegistry), + typeof(CasinoGameTable), + typeof(BlackjackGame), + typeof(RideTheBusGame), + typeof(SlotMachine), + typeof(CasinoPlayerSnapshot), + typeof(CasinoCardSnapshot), + typeof(SlotSpinSnapshot) + }; + + foreach (Type type in publicCasinoTypes) + { + IEnumerable exposedTypes = type + .GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) + .SelectMany(GetExposedTypes); + + Assert.DoesNotContain(exposedTypes, exposed => + exposed.Namespace?.Contains("ScheduleOne.Casino", StringComparison.Ordinal) == true); + } + } + + private static IEnumerable GetExposedTypes(MemberInfo member) + { + switch (member) + { + case PropertyInfo property: + yield return property.PropertyType; + break; + case FieldInfo field: + yield return field.FieldType; + break; + case EventInfo eventInfo when eventInfo.EventHandlerType != null: + yield return eventInfo.EventHandlerType; + break; + case MethodInfo method: + yield return method.ReturnType; + foreach (ParameterInfo parameter in method.GetParameters()) + yield return parameter.ParameterType; + break; + case ConstructorInfo constructor: + foreach (ParameterInfo parameter in constructor.GetParameters()) + yield return parameter.ParameterType; + break; + } + } +} diff --git a/S1API/Casino/CasinoGameRegistry.cs b/S1API/Casino/CasinoGameRegistry.cs new file mode 100644 index 00000000..678dd879 --- /dev/null +++ b/S1API/Casino/CasinoGameRegistry.cs @@ -0,0 +1,282 @@ +#if IL2CPPMELON +using S1Casino = Il2CppScheduleOne.Casino; +#elif MONOMELON +using S1Casino = ScheduleOne.Casino; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Lifecycle; +using S1API.Logging; +using UnityEngine; + +namespace S1API.Casino +{ + /// + /// Discovers native casino games and publishes their synchronized lifecycle transitions. + /// + /// + /// This first-version API is intentionally read-only. It does not expose native RPCs, payout + /// replacement, game authoring, or methods that mutate bets and round state. + /// + public static class CasinoGameRegistry + { + private static readonly Log Logger = new Log("CasinoGameRegistry"); + private static readonly Dictionary BlackjackGames = new Dictionary(); + private static readonly Dictionary RideTheBusGames = new Dictionary(); + private static readonly Dictionary SlotMachines = new Dictionary(); + private static readonly Dictionary ActiveSpins = new Dictionary(); + private static bool _lifecycleHooked; + + /// Raised after a blackjack table changes stage. + public static event Action? BlackjackStageChanged; + + /// Raised when a blackjack table enters the dealing stage. + public static event Action? BlackjackRoundStarted; + + /// Raised when a blackjack table returns to the waiting stage. + public static event Action? BlackjackRoundEnded; + + /// Raised after the Ride the Bus table changes stage. + public static event Action? RideTheBusStageChanged; + + /// Raised when a Ride the Bus table begins its first question. + public static event Action? RideTheBusRoundStarted; + + /// Raised when a Ride the Bus table returns to the waiting stage. + public static event Action? RideTheBusRoundEnded; + + /// Raised when a slot machine begins a synchronized spin. + public static event Action? SlotSpinStarted; + + /// Raised when a slot machine displays a synchronized spin outcome. + public static event Action? SlotSpinCompleted; + + /// Gets immutable wrappers for all active blackjack tables. + public static IReadOnlyList GetBlackjackGames() + { + EnsureLifecycleHook(); + var nativeGames = UnityEngine.Object.FindObjectsOfType(); + var games = new List(nativeGames.Length); + for (int i = 0; i < nativeGames.Length; i++) + { + if (nativeGames[i] != null) + games.Add(Wrap(nativeGames[i])); + } + return new ReadOnlyCollection(games); + } + + /// Gets immutable wrappers for all active Ride the Bus tables. + public static IReadOnlyList GetRideTheBusGames() + { + EnsureLifecycleHook(); + var nativeGames = UnityEngine.Object.FindObjectsOfType(); + var games = new List(nativeGames.Length); + for (int i = 0; i < nativeGames.Length; i++) + { + if (nativeGames[i] != null) + games.Add(Wrap(nativeGames[i])); + } + return new ReadOnlyCollection(games); + } + + /// Gets immutable wrappers for all active native slot machines. + public static IReadOnlyList GetSlotMachines() + { + EnsureLifecycleHook(); + var nativeMachines = UnityEngine.Object.FindObjectsOfType(); + var machines = new List(nativeMachines.Length); + for (int i = 0; i < nativeMachines.Length; i++) + { + if (nativeMachines[i] != null) + machines.Add(Wrap(nativeMachines[i])); + } + return new ReadOnlyCollection(machines); + } + + /// Finds the active slot machine nearest to a world position. + /// The world position to search from. + /// The maximum search distance. + /// The nearest managed slot-machine wrapper, or when none is in range. + public static SlotMachine? FindNearestSlotMachine(Vector3 position, float maxDistance) + { + S1Casino.SlotMachine? native = SlotMachineHelper.FindNearestNativeSlotMachine(position, maxDistance); + return native == null ? null : Wrap(native); + } + + /// Gets immutable wrappers for all active blackjack and Ride the Bus tables. + public static IReadOnlyList GetTables() + { + var tables = new List(); + tables.AddRange(GetBlackjackGames()); + tables.AddRange(GetRideTheBusGames()); + return new ReadOnlyCollection(tables); + } + + internal static BlackjackGame Wrap(S1Casino.BlackjackGameController native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!BlackjackGames.TryGetValue(key, out BlackjackGame? game)) + { + game = new BlackjackGame(native); + BlackjackGames[key] = game; + } + return game; + } + + internal static RideTheBusGame Wrap(S1Casino.RTBGameController native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!RideTheBusGames.TryGetValue(key, out RideTheBusGame? game)) + { + game = new RideTheBusGame(native); + RideTheBusGames[key] = game; + } + return game; + } + + internal static SlotMachine Wrap(S1Casino.SlotMachine native) + { + EnsureLifecycleHook(); + int key = native.GetInstanceID(); + if (!SlotMachines.TryGetValue(key, out SlotMachine? machine)) + { + machine = new SlotMachine(native); + SlotMachines[key] = machine; + } + return machine; + } + + internal static void NotifyBlackjackStageChanged( + S1Casino.BlackjackGameController native, + BlackjackStage previous, + BlackjackStage current) + { + if (previous == current) + return; + + BlackjackGame game = Wrap(native); + game.NotifyStageChanged(previous, current); + InvokeSafely(BlackjackStageChanged, game, previous, current, nameof(BlackjackStageChanged)); + + if (current == BlackjackStage.Dealing && previous == BlackjackStage.WaitingForPlayers) + InvokeSafely(BlackjackRoundStarted, game, nameof(BlackjackRoundStarted)); + if (current == BlackjackStage.WaitingForPlayers) + InvokeSafely(BlackjackRoundEnded, game, nameof(BlackjackRoundEnded)); + } + + internal static void NotifyRideTheBusStageChanged( + S1Casino.RTBGameController native, + RideTheBusStage previous, + RideTheBusStage current) + { + if (previous == current) + return; + + RideTheBusGame game = Wrap(native); + game.NotifyStageChanged(previous, current); + InvokeSafely(RideTheBusStageChanged, game, previous, current, nameof(RideTheBusStageChanged)); + + if (current == RideTheBusStage.RedOrBlack && previous == RideTheBusStage.WaitingForPlayers) + InvokeSafely(RideTheBusRoundStarted, game, nameof(RideTheBusRoundStarted)); + if (current == RideTheBusStage.WaitingForPlayers) + InvokeSafely(RideTheBusRoundEnded, game, nameof(RideTheBusRoundEnded)); + } + + internal static void NotifySlotSpinStarted( + S1Casino.SlotMachine native, + SlotSpinSnapshot snapshot) + { + int key = native.GetInstanceID(); + ActiveSpins[key] = snapshot; + + SlotMachine machine = Wrap(native); + machine.NotifySpinStarted(snapshot); + InvokeSafely(SlotSpinStarted, machine, snapshot, nameof(SlotSpinStarted)); + } + + internal static void NotifySlotSpinCompleted( + S1Casino.SlotMachine native, + SlotOutcome outcome, + int winAmount) + { + int key = native.GetInstanceID(); + if (!ActiveSpins.TryGetValue(key, out SlotSpinSnapshot? started)) + return; + + ActiveSpins.Remove(key); + SlotSpinSnapshot completed = started.Complete(outcome, winAmount); + SlotMachine machine = Wrap(native); + machine.NotifySpinCompleted(completed); + InvokeSafely(SlotSpinCompleted, machine, completed, nameof(SlotSpinCompleted)); + } + + internal static void LogSubscriberFailure(string eventName, Exception exception) => + Logger.Warning($"A {eventName} subscriber failed: {exception.Message}"); + + private static void EnsureLifecycleHook() + { + if (_lifecycleHooked) + return; + + GameLifecycle.OnPreSceneChange += ClearSceneState; + _lifecycleHooked = true; + } + + private static void ClearSceneState() + { + BlackjackGames.Clear(); + RideTheBusGames.Clear(); + SlotMachines.Clear(); + ActiveSpins.Clear(); + } + + private static void InvokeSafely(Action? handlers, T value, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value); } + catch (Exception ex) { LogSubscriberFailure(eventName, ex); } + } + } + + private static void InvokeSafely( + Action? handlers, + T1 value1, + T2 value2, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value1, value2); } + catch (Exception ex) { LogSubscriberFailure(eventName, ex); } + } + } + + private static void InvokeSafely( + Action? handlers, + T1 value1, + T2 value2, + T3 value3, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value1, value2, value3); } + catch (Exception ex) { LogSubscriberFailure(eventName, ex); } + } + } + } +} diff --git a/S1API/Casino/CasinoGames.cs b/S1API/Casino/CasinoGames.cs new file mode 100644 index 00000000..5cead3eb --- /dev/null +++ b/S1API/Casino/CasinoGames.cs @@ -0,0 +1,440 @@ +#if IL2CPPMELON +using S1Casino = Il2CppScheduleOne.Casino; +using S1PlayerScripts = Il2CppScheduleOne.PlayerScripts; +#elif MONOMELON +using S1Casino = ScheduleOne.Casino; +using S1PlayerScripts = ScheduleOne.PlayerScripts; +#endif + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; +using S1API.Entities; +using S1API.Internal.Utils; +using UnityEngine; + +namespace S1API.Casino +{ + /// + /// Read-only managed view of a native multiplayer casino table. + /// + public abstract class CasinoGameTable + { + private static readonly IReadOnlyList EmptyPlayers = + new ReadOnlyCollection(Array.Empty()); + + internal CasinoGameTable(S1Casino.CasinoGameController controller) + { + Controller = controller ?? throw new ArgumentNullException(nameof(controller)); + } + + internal S1Casino.CasinoGameController Controller { get; } + + /// Gets the scene object name. + public string Name => Controller.gameObject?.name ?? string.Empty; + + /// Gets the current world position. + public Vector3 Position => Controller.transform.position; + + /// + /// Gets whether this table's interface is open for the local player. + /// + public bool IsOpen => Controller.IsOpen; + + /// Gets whether the table is currently accepting ready players. + public bool IsWaitingForPlayers => Controller.IsWaitingForPlayers(); + + /// Gets the local player's currently selected bet. + public float LocalBet => Controller.LocalPlayerBet; + + /// Gets the native minimum and maximum bet. + public CasinoBetLimits BetLimits + { + get + { + Controller.GetBetLimits(out float minimum, out float maximum); + return new CasinoBetLimits(minimum, maximum); + } + } + + /// + /// Gets an immutable snapshot of players currently seated at the table. + /// + public IReadOnlyList Players + { + get + { + S1Casino.CasinoGamePlayers? players = Controller.Players; + if (players == null) + return EmptyPlayers; + + var snapshots = new List(); + for (int seatIndex = 0; seatIndex < players.PlayerLimit; seatIndex++) + { + S1PlayerScripts.Player? nativePlayer = players.GetPlayer(seatIndex); + if (nativePlayer == null) + continue; + + bool ready = false; + try + { + S1Casino.CasinoGamePlayerData? data = players.GetPlayerData(nativePlayer); + ready = data != null && data.GetData("Ready"); + } + catch + { + } + + snapshots.Add(new CasinoPlayerSnapshot( + ResolvePlayer(nativePlayer), + nativePlayer.PlayerName ?? string.Empty, + seatIndex, + players.GetPlayerScore(nativePlayer), + ready)); + } + + return snapshots.Count == 0 + ? EmptyPlayers + : new ReadOnlyCollection(snapshots); + } + } + + private static Player? ResolvePlayer(S1PlayerScripts.Player nativePlayer) => + Player.All.FirstOrDefault(player => player.S1Player == nativePlayer); + } + + /// + /// Read-only managed view of a native blackjack table. + /// + public sealed class BlackjackGame : CasinoGameTable + { + private static readonly IReadOnlyList EmptyCards = + new ReadOnlyCollection(Array.Empty()); + + internal BlackjackGame(S1Casino.BlackjackGameController controller) + : base(controller) + { + Native = controller; + } + + internal S1Casino.BlackjackGameController Native { get; } + + /// Raised after the native blackjack stage changes. + public event Action? StageChanged; + + /// Raised when the table enters the dealing stage. + public event Action? RoundStarted; + + /// Raised when the table returns to its waiting stage. + public event Action? RoundEnded; + + /// Gets the current round stage. + public BlackjackStage Stage => (BlackjackStage)(int)Native.CurrentStage; + + /// Gets the current dealer score visible to this peer. + public int DealerScore => Native.DealerScore; + + /// Gets the current local-player score. + public int LocalPlayerScore => Native.LocalPlayerScore; + + /// Gets whether the local player has a natural blackjack. + public bool IsLocalPlayerBlackjack => Native.IsLocalPlayerBlackjack; + + /// Gets whether the local player is bust. + public bool IsLocalPlayerBust => Native.IsLocalPlayerBust; + + /// Gets whether the local player belongs to the active round. + public bool IsLocalPlayerInRound => Native.IsLocalPlayerInCurrentRound; + + /// Gets the number of seated players currently marked ready. + public int ReadyPlayerCount => Native.GetPlayersReadyCount(); + + /// + /// Gets an immutable snapshot of a seated player's current hand. + /// + /// The zero-based seat index. + public IReadOnlyList GetPlayerHand(int seatIndex) + { + if (seatIndex < 0 || seatIndex >= Native.Players.PlayerLimit) + return EmptyCards; + +#if IL2CPPMELON + var cards = Native.GetPlayerCards(seatIndex); +#else + var method = typeof(S1Casino.BlackjackGameController).GetMethod( + "GetPlayerCards", + BindingFlags.Instance | BindingFlags.NonPublic); + var cards = method?.Invoke(Native, new object[] { seatIndex }) + as List; +#endif + return FreezeCards(cards); + } + + /// Gets an immutable snapshot of the dealer's current hand. + public IReadOnlyList DealerHand + { + get + { +#if IL2CPPMELON + var cards = Native.dealerHand; +#else + var dealerHandField = typeof(S1Casino.BlackjackGameController).GetField( + "dealerHand", + BindingFlags.Instance | BindingFlags.NonPublic); + var cards = dealerHandField?.GetValue(Native) as List; +#endif + return FreezeCards(cards); + } + } + + internal void NotifyStageChanged(BlackjackStage previous, BlackjackStage current) + { + InvokeSafely(StageChanged, previous, current, nameof(StageChanged)); + if (current == BlackjackStage.Dealing && previous == BlackjackStage.WaitingForPlayers) + InvokeSafely(RoundStarted, nameof(RoundStarted)); + if (current == BlackjackStage.WaitingForPlayers && previous != current) + InvokeSafely(RoundEnded, nameof(RoundEnded)); + } + + private static IReadOnlyList FreezeCards( +#if IL2CPPMELON + Il2CppSystem.Collections.Generic.List? cards) +#else + List? cards) +#endif + { + if (cards == null || cards.Count == 0) + return EmptyCards; + + var snapshots = new List(cards.Count); + for (int i = 0; i < cards.Count; i++) + { + S1Casino.PlayingCard? card = cards[i]; + if (card != null) + snapshots.Add(ToSnapshot(card)); + } + + return snapshots.Count == 0 + ? EmptyCards + : new ReadOnlyCollection(snapshots); + } + + internal static CasinoCardSnapshot ToSnapshot(S1Casino.PlayingCard card) => + new CasinoCardSnapshot( + card.CardID ?? string.Empty, + (CasinoCardSuit)(int)card.Suit, + (CasinoCardValue)(int)card.Value, + card.IsFaceUp); + + private static void InvokeSafely(Action? handlers, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + private static void InvokeSafely( + Action? handlers, + BlackjackStage previous, + BlackjackStage current, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(previous, current); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + } + + /// + /// Read-only managed view of the native Ride the Bus table. + /// + public sealed class RideTheBusGame : CasinoGameTable + { + private static readonly IReadOnlyList EmptyCards = + new ReadOnlyCollection(Array.Empty()); + + internal RideTheBusGame(S1Casino.RTBGameController controller) + : base(controller) + { + Native = controller; + } + + internal S1Casino.RTBGameController Native { get; } + + /// Raised after the native Ride the Bus stage changes. + public event Action? StageChanged; + + /// Raised when a new Ride the Bus round begins. + public event Action? RoundStarted; + + /// Raised when the table returns to its waiting stage. + public event Action? RoundEnded; + + /// Gets the current round stage. + public RideTheBusStage Stage => (RideTheBusStage)(int)Native.CurrentStage; + + /// Gets whether a question is currently accepting answers. + public bool IsQuestionActive => Native.IsQuestionActive; + + /// Gets the local player's current bet multiplier. + public float LocalBetMultiplier => Native.LocalPlayerBetMultiplier; + + /// Gets the local player's multiplied bet. + public float MultipliedLocalBet => Native.MultipliedLocalPlayerBet; + + /// Gets the answer time remaining on the current peer. + public float RemainingAnswerTime => Native.RemainingAnswerTime; + + /// Gets whether the local player belongs to the active round. + public bool IsLocalPlayerInRound => Native.IsLocalPlayerInCurrentRound; + + /// Gets the number of seated players currently marked ready. + public int ReadyPlayerCount => Native.GetPlayersReadyCount(); + + /// Gets the number of active-round players who submitted an answer. + public int AnsweredPlayerCount => Native.GetAnsweredPlayersCount(); + + /// Gets immutable snapshots of cards currently assigned by the table. + public IReadOnlyList Cards + { + get + { + if (Native.Cards == null || Native.Cards.Length == 0) + return EmptyCards; + + var cards = new List(); + for (int i = 0; i < Native.Cards.Length; i++) + { + S1Casino.PlayingCard? card = Native.Cards[i]; + if (card != null && (int)card.Value != (int)CasinoCardValue.Blank) + cards.Add(BlackjackGame.ToSnapshot(card)); + } + + return cards.Count == 0 + ? EmptyCards + : new ReadOnlyCollection(cards); + } + } + + internal void NotifyStageChanged(RideTheBusStage previous, RideTheBusStage current) + { + InvokeSafely(StageChanged, previous, current, nameof(StageChanged)); + if (current == RideTheBusStage.RedOrBlack && previous == RideTheBusStage.WaitingForPlayers) + InvokeSafely(RoundStarted, nameof(RoundStarted)); + if (current == RideTheBusStage.WaitingForPlayers && previous != current) + InvokeSafely(RoundEnded, nameof(RoundEnded)); + } + + private static void InvokeSafely(Action? handlers, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + private static void InvokeSafely( + Action? handlers, + RideTheBusStage previous, + RideTheBusStage current, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(previous, current); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + } + + /// + /// Read-only managed view of a native slot machine. + /// + public sealed class SlotMachine + { + internal SlotMachine(S1Casino.SlotMachine machine) + { + Native = machine ?? throw new ArgumentNullException(nameof(machine)); + } + + internal S1Casino.SlotMachine Native { get; } + + /// Raised when a synchronized spin begins. + public event Action? SpinStarted; + + /// Raised when a synchronized spin displays its outcome. + public event Action? SpinCompleted; + + /// Gets the scene object name. + public string Name => Native.gameObject?.name ?? string.Empty; + + /// Gets the current world position. + public Vector3 Position => Native.transform.position; + + /// Gets whether the reels are currently spinning. + public bool IsSpinning => Native.IsSpinning; + + /// Gets the machine's currently selected bet. + public int CurrentBet + { + get + { + object? value = ReflectionUtils.TryGetFieldOrProperty(Native, "currentBetAmount"); + return value is int bet ? bet : 0; + } + } + + /// Gets the immutable native bet choices. + public IReadOnlyList AvailableBets + { + get + { + var nativeAmounts = S1Casino.SlotMachine.BetAmounts; + var amounts = new int[nativeAmounts.Length]; + for (int i = 0; i < nativeAmounts.Length; i++) + amounts[i] = nativeAmounts[i]; + return new ReadOnlyCollection(amounts); + } + } + + internal void NotifySpinStarted(SlotSpinSnapshot snapshot) => + InvokeSafely(SpinStarted, snapshot, nameof(SpinStarted)); + + internal void NotifySpinCompleted(SlotSpinSnapshot snapshot) => + InvokeSafely(SpinCompleted, snapshot, nameof(SpinCompleted)); + + private static void InvokeSafely( + Action? handlers, + SlotSpinSnapshot snapshot, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(snapshot); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + } +} diff --git a/S1API/Casino/CasinoSnapshots.cs b/S1API/Casino/CasinoSnapshots.cs new file mode 100644 index 00000000..c1b338f9 --- /dev/null +++ b/S1API/Casino/CasinoSnapshots.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using S1API.Entities; + +namespace S1API.Casino +{ + /// + /// Represents the native minimum and maximum bet accepted by a casino table. + /// + public readonly struct CasinoBetLimits + { + /// + /// Creates an immutable bet-limit snapshot. + /// + public CasinoBetLimits(float minimum, float maximum) + { + Minimum = minimum; + Maximum = maximum; + } + + /// Gets the minimum accepted bet. + public float Minimum { get; } + + /// Gets the maximum accepted bet. + public float Maximum { get; } + } + + /// + /// Card suits used by the native casino games. + /// + public enum CasinoCardSuit + { + /// Spades. + Spades = 0, + /// Hearts. + Hearts = 1, + /// Diamonds. + Diamonds = 2, + /// Clubs. + Clubs = 3 + } + + /// + /// Card values used by the native casino games. + /// + public enum CasinoCardValue + { + /// No card value is assigned. + Blank = 0, + /// Ace. + Ace = 1, + /// Two. + Two = 2, + /// Three. + Three = 3, + /// Four. + Four = 4, + /// Five. + Five = 5, + /// Six. + Six = 6, + /// Seven. + Seven = 7, + /// Eight. + Eight = 8, + /// Nine. + Nine = 9, + /// Ten. + Ten = 10, + /// Jack. + Jack = 11, + /// Queen. + Queen = 12, + /// King. + King = 13 + } + + /// + /// Immutable public representation of a casino playing card. + /// + public readonly struct CasinoCardSnapshot + { + /// + /// Creates an immutable card snapshot. + /// + public CasinoCardSnapshot(string id, CasinoCardSuit suit, CasinoCardValue value, bool isFaceUp) + { + Id = id ?? string.Empty; + Suit = suit; + Value = value; + IsFaceUp = isFaceUp; + } + + /// Gets the scene-local native card identifier. + public string Id { get; } + + /// Gets the card suit. + public CasinoCardSuit Suit { get; } + + /// Gets the card value. + public CasinoCardValue Value { get; } + + /// Gets whether the card is face up for the current client. + public bool IsFaceUp { get; } + } + + /// + /// Immutable player state captured from a casino table. + /// + public sealed class CasinoPlayerSnapshot + { + internal CasinoPlayerSnapshot(Player? player, string name, int seatIndex, int score, bool isReady) + { + Player = player; + Name = name; + SeatIndex = seatIndex; + Score = score; + IsReady = isReady; + } + + /// + /// Gets the S1API player wrapper when that player has completed S1API initialization. + /// + public Player? Player { get; } + + /// Gets the current native player name. + public string Name { get; } + + /// Gets the zero-based table seat index. + public int SeatIndex { get; } + + /// Gets the synchronized score stored by the table. + public int Score { get; } + + /// Gets whether the player has marked themselves ready. + public bool IsReady { get; } + } + + /// + /// Stages in a native blackjack round. + /// + public enum BlackjackStage + { + /// The table is accepting players. + WaitingForPlayers = 0, + /// Initial cards are being dealt. + Dealing = 1, + /// A player is taking their turn. + PlayerTurn = 2, + /// The dealer is taking their turn. + DealerTurn = 3, + /// The round is resolving payouts. + Ending = 4 + } + + /// + /// Native blackjack payout classifications. + /// + public enum BlackjackPayout + { + /// No payout. + None = 0, + /// Natural blackjack. + Blackjack = 1, + /// Standard win. + Win = 2, + /// Push; the original bet is returned. + Push = 3 + } + + /// + /// Stages in a native Ride the Bus round. + /// + public enum RideTheBusStage + { + /// The table is accepting players. + WaitingForPlayers = 0, + /// The player predicts red or black. + RedOrBlack = 1, + /// The player predicts higher or lower. + HigherOrLower = 2, + /// The player predicts inside or outside. + InsideOrOutside = 3, + /// The player predicts the suit. + Suit = 4 + } + + /// + /// Symbols displayed by a native slot machine. + /// + public enum SlotSymbol + { + /// Cherry. + Cherry = 0, + /// Lemon. + Lemon = 1, + /// Grape. + Grape = 2, + /// Watermelon. + Watermelon = 3, + /// Bell. + Bell = 4, + /// Seven. + Seven = 5 + } + + /// + /// Native slot-machine outcome classifications. + /// + public enum SlotOutcome + { + /// Three sevens. + Jackpot = 0, + /// Three bells. + BigWin = 1, + /// Three matching fruit symbols. + SmallWin = 2, + /// Three fruit symbols. + MiniWin = 3, + /// No winning combination. + NoWin = 4 + } + + /// + /// Immutable state for a slot spin as observed by the current peer. + /// + public sealed class SlotSpinSnapshot + { + internal SlotSpinSnapshot( + int bet, + IReadOnlyList symbols, + bool wasStartedByLocalPlayer, + SlotOutcome? outcome, + int? winAmount) + { + Bet = bet; + Symbols = symbols; + WasStartedByLocalPlayer = wasStartedByLocalPlayer; + Outcome = outcome; + WinAmount = winAmount; + } + + /// Gets the bet used for this spin. + public int Bet { get; } + + /// Gets the immutable ordered reel symbols. + public IReadOnlyList Symbols { get; } + + /// Gets whether the native spinner connection belongs to this client. + public bool WasStartedByLocalPlayer { get; } + + /// Gets the outcome after completion, or null while spinning. + public SlotOutcome? Outcome { get; } + + /// Gets the win amount after completion, or null while spinning. + public int? WinAmount { get; } + + internal SlotSpinSnapshot Complete(SlotOutcome outcome, int winAmount) => + new SlotSpinSnapshot(Bet, Symbols, WasStartedByLocalPlayer, outcome, winAmount); + + internal static IReadOnlyList Freeze(IList symbols) + { + if (symbols.Count == 0) + return new ReadOnlyCollection(Array.Empty()); + + var copy = new SlotSymbol[symbols.Count]; + symbols.CopyTo(copy, 0); + return new ReadOnlyCollection(copy); + } + } +} diff --git a/S1API/Casino/SlotMachineHelper.cs b/S1API/Casino/SlotMachineHelper.cs index 6c992cfc..5a8888f1 100644 --- a/S1API/Casino/SlotMachineHelper.cs +++ b/S1API/Casino/SlotMachineHelper.cs @@ -13,6 +13,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using S1API.Entities; using S1API.Lifecycle; using S1API.Logging; @@ -282,7 +283,7 @@ public static bool UseSlotMachine(NPC npc, Vector3 machinePosition, int betAmoun if (_isSceneChangeInProgress) return false; - var machine = FindNearestSlotMachine(machinePosition, maxSearchDistance); + var machine = FindNearestNativeSlotMachine(machinePosition, maxSearchDistance); if (machine == null) { Logger.Warning($"No slot machine found near position {machinePosition}"); @@ -325,6 +326,18 @@ public static bool UseSlotMachine(NPC npc, Vector3 machinePosition, int betAmoun SpinSlotMachineForNPC(npc, machine, symbols, betAmount, activeSpin)); #endif + var managedSymbols = new List(symbols.Length); + for (int i = 0; i < symbols.Length; i++) + managedSymbols.Add((SlotSymbol)(int)symbols[i]); + + CasinoGameRegistry.NotifySlotSpinStarted( + machine, + new SlotSpinSnapshot( + betAmount, + SlotSpinSnapshot.Freeze(managedSymbols), + wasStartedByLocalPlayer: false, + outcome: null, + winAmount: null)); RegisterActiveSpin(activeSpin); return true; @@ -337,12 +350,21 @@ public static bool UseSlotMachine(NPC npc, Vector3 machinePosition, int betAmoun } /// - /// Finds the nearest slot machine to a given position. + /// Finds the nearest native slot machine to a given position. /// /// The position to search from. /// Maximum distance to search. - /// The nearest slot machine, or null if none found. + /// The nearest native slot machine, or null if none found. + /// + /// This compatibility method exposes a game type. New code should use + /// . + /// + [Obsolete("Use CasinoGameRegistry.FindNearestSlotMachine to receive an S1API managed wrapper.")] + [EditorBrowsable(EditorBrowsableState.Never)] public static S1Casino.SlotMachine? FindNearestSlotMachine(Vector3 position, float maxDistance) + => FindNearestNativeSlotMachine(position, maxDistance); + + internal static S1Casino.SlotMachine? FindNearestNativeSlotMachine(Vector3 position, float maxDistance) { try { diff --git a/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs b/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs index 7126915d..174a742a 100644 --- a/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs +++ b/S1API/Entities/Schedule/ActionSpecs/UseSlotMachineSpec.cs @@ -222,13 +222,13 @@ void IScheduleActionSpec.ApplyTo(NPCSchedule schedule) { Logger.Warning($"[{npc.ID}] Initial position not reachable, searching for nearest slot machine"); // Try to find the nearest reachable slot machine - var machine = SlotMachineHelper.FindNearestSlotMachine(targetPosition, MaxSearchDistance * 2f); + var machine = CasinoGameRegistry.FindNearestSlotMachine(targetPosition, MaxSearchDistance * 2f); if (machine != null) { // Check if this machine is reachable - if (npc.Movement.CanGetTo(machine.transform.position)) + if (npc.Movement.CanGetTo(machine.Position)) { - targetPosition = machine.transform.position; + targetPosition = machine.Position; } else { @@ -315,10 +315,10 @@ private static System.Collections.IEnumerator WaitForArrivalThenGamble( { Logger.Warning($"[{npc.ID}] NPC can't pathfind to target, searching for alternative"); // NPC can't reach the destination - try to find the nearest slot machine instead - var machine = SlotMachineHelper.FindNearestSlotMachine(targetPosition, maxDistance * 2f); + var machine = CasinoGameRegistry.FindNearestSlotMachine(targetPosition, maxDistance * 2f); if (machine != null) { - targetPosition = machine.transform.position; + targetPosition = machine.Position; } else { diff --git a/S1API/Internal/Patches/CasinoGamePatches.cs b/S1API/Internal/Patches/CasinoGamePatches.cs new file mode 100644 index 00000000..5164e3e1 --- /dev/null +++ b/S1API/Internal/Patches/CasinoGamePatches.cs @@ -0,0 +1,128 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using S1Casino = Il2CppScheduleOne.Casino; +using S1NetworkConnection = Il2CppFishNet.Connection.NetworkConnection; +#elif MONOMELON +using S1Casino = ScheduleOne.Casino; +using S1NetworkConnection = FishNet.Connection.NetworkConnection; +#endif + +using System.Collections.Generic; +using HarmonyLib; +using S1API.Casino; + +namespace S1API.Internal.Patches +{ + /// + /// Bridges native casino state transitions to the read-only managed casino API. + /// + [HarmonyPatch] + internal static class CasinoGamePatches + { + [HarmonyPatch(typeof(S1Casino.BlackjackGameController), "set_CurrentStage")] + [HarmonyPrefix] + private static void BlackjackStagePrefix( + S1Casino.BlackjackGameController __instance, + out BlackjackStage __state) + { + __state = (BlackjackStage)(int)__instance.CurrentStage; + } + + [HarmonyPatch(typeof(S1Casino.BlackjackGameController), "set_CurrentStage")] + [HarmonyPostfix] + private static void BlackjackStagePostfix( + S1Casino.BlackjackGameController __instance, + S1Casino.BlackjackGameController.EStage __0, + BlackjackStage __state) + { + CasinoGameRegistry.NotifyBlackjackStageChanged( + __instance, + __state, + (BlackjackStage)(int)__0); + } + + [HarmonyPatch(typeof(S1Casino.RTBGameController), "set_CurrentStage")] + [HarmonyPrefix] + private static void RideTheBusStagePrefix( + S1Casino.RTBGameController __instance, + out RideTheBusStage __state) + { + __state = (RideTheBusStage)(int)__instance.CurrentStage; + } + + [HarmonyPatch(typeof(S1Casino.RTBGameController), "set_CurrentStage")] + [HarmonyPostfix] + private static void RideTheBusStagePostfix( + S1Casino.RTBGameController __instance, + S1Casino.RTBGameController.EStage __0, + RideTheBusStage __state) + { + CasinoGameRegistry.NotifyRideTheBusStageChanged( + __instance, + __state, + (RideTheBusStage)(int)__0); + } + + [HarmonyPatch(typeof(S1Casino.SlotMachine), "RpcLogic___StartSpin_2659526290")] + [HarmonyPrefix] + private static void SlotSpinPrefix( + S1Casino.SlotMachine __instance, + S1NetworkConnection __0, +#if IL2CPPMELON + Il2CppStructArray __1, +#else + S1Casino.SlotMachine.ESymbol[] __1, +#endif + int __2, + out SlotStartPatchState __state) + { + var symbols = new List(__1.Length); + for (int i = 0; i < __1.Length; i++) + symbols.Add((SlotSymbol)(int)__1[i]); + + __state = new SlotStartPatchState( + __instance.IsSpinning, + new SlotSpinSnapshot( + __2, + SlotSpinSnapshot.Freeze(symbols), + __0 != null && __0.IsLocalClient, + outcome: null, + winAmount: null)); + } + + [HarmonyPatch(typeof(S1Casino.SlotMachine), "RpcLogic___StartSpin_2659526290")] + [HarmonyPostfix] + private static void SlotSpinPostfix( + S1Casino.SlotMachine __instance, + SlotStartPatchState __state) + { + if (!__state.WasSpinning && __instance.IsSpinning) + CasinoGameRegistry.NotifySlotSpinStarted(__instance, __state.Snapshot); + } + + [HarmonyPatch(typeof(S1Casino.SlotMachine), "DisplayOutcome")] + [HarmonyPostfix] + private static void SlotOutcomePostfix( + S1Casino.SlotMachine __instance, + S1Casino.SlotMachine.EOutcome __0, + int __1) + { + CasinoGameRegistry.NotifySlotSpinCompleted( + __instance, + (SlotOutcome)(int)__0, + __1); + } + + private readonly struct SlotStartPatchState + { + internal SlotStartPatchState(bool wasSpinning, SlotSpinSnapshot snapshot) + { + WasSpinning = wasSpinning; + Snapshot = snapshot; + } + + internal bool WasSpinning { get; } + internal SlotSpinSnapshot Snapshot { get; } + } + } +} diff --git a/S1API/docs/casino-games.md b/S1API/docs/casino-games.md new file mode 100644 index 00000000..de454d48 --- /dev/null +++ b/S1API/docs/casino-games.md @@ -0,0 +1,116 @@ +# Casino game state + +S1API exposes read-only managed wrappers for the casino's blackjack table, Ride the Bus table, and slot machines. The wrappers provide discovery, synchronized state snapshots, and round or spin lifecycle events without exposing native casino controllers or RPC methods. + +This API observes the game. It does not create custom casino games, replace payouts, submit bets or answers, change readiness, or invoke client/server RPCs. + +## Discover games + +Casino objects belong to the gameplay scene. Query the registry after the game has loaded, such as from `GameLifecycle.OnLoadComplete`: + +```csharp +using S1API.Casino; +using S1API.Lifecycle; + +GameLifecycle.OnLoadComplete += () => +{ + IReadOnlyList blackjack = CasinoGameRegistry.GetBlackjackGames(); + IReadOnlyList rideTheBus = CasinoGameRegistry.GetRideTheBusGames(); + IReadOnlyList slots = CasinoGameRegistry.GetSlotMachines(); +}; +``` + +`GetTables()` returns blackjack and Ride the Bus through their shared `CasinoGameTable` base. Slot machines are separate because they do not have seated-player or table-bet state. + +Use `CasinoGameRegistry.FindNearestSlotMachine(position, maxDistance)` when you need the nearest slot machine without exposing the game's native casino type: + +```csharp +SlotMachine? nearest = CasinoGameRegistry.FindNearestSlotMachine(transform.position, 10f); +if (nearest != null) + MelonLogger.Msg($"Nearest slot machine: {nearest.Name} at {nearest.Position}"); +``` + +Registry results are immutable snapshots. Calling a discovery method again reflects the objects in the current scene, while wrappers for the same live scene object retain their identity so instance event subscriptions remain attached. + +## Shared table state + +Every `CasinoGameTable` exposes: + +- `Name` and `Position` +- `IsOpen`, which reports whether this table's interface is open for the local player +- `IsWaitingForPlayers` +- `LocalBet` +- `BetLimits` +- `Players`, an immutable snapshot containing managed `Player` wrappers when available, seat indices, synchronized scores, and ready state + +Local-player properties describe the current client. Stage, player, card, score, and spin data reflect the native state received by that peer. + +## Blackjack + +```csharp +BlackjackGame table = CasinoGameRegistry.GetBlackjackGames()[0]; + +BlackjackStage stage = table.Stage; +CasinoBetLimits limits = table.BetLimits; +IReadOnlyList dealerHand = table.DealerHand; + +foreach (CasinoPlayerSnapshot player in table.Players) +{ + IReadOnlyList hand = table.GetPlayerHand(player.SeatIndex); + MelonLogger.Msg($"{player.Name}: table score {player.Score}, cards {hand.Count}"); +} +``` + +Blackjack also exposes the dealer score, local score, local blackjack/bust flags, ready-player count, and whether the local player belongs to the active round. + +Subscribe globally when you want to observe every table: + +```csharp +CasinoGameRegistry.BlackjackStageChanged += (table, previous, current) => + MelonLogger.Msg($"{table.Name}: {previous} -> {current}"); + +CasinoGameRegistry.BlackjackRoundStarted += table => + MelonLogger.Msg($"Round started at {table.Name}"); + +CasinoGameRegistry.BlackjackRoundEnded += table => + MelonLogger.Msg($"Round ended at {table.Name}"); +``` + +The same events are available on an individual `BlackjackGame` wrapper. + +## Ride the Bus + +`RideTheBusGame` exposes its stage, question-active flag, remaining answer time, local bet multiplier, multiplied local bet, ready and answered player counts, active-round membership, and immutable card snapshots. + +```csharp +RideTheBusGame table = CasinoGameRegistry.GetRideTheBusGames()[0]; + +CasinoGameRegistry.RideTheBusStageChanged += (game, previous, current) => + MelonLogger.Msg($"Ride the Bus: {previous} -> {current}"); + +CasinoGameRegistry.RideTheBusRoundStarted += game => + MelonLogger.Msg("Ride the Bus round started"); + +CasinoGameRegistry.RideTheBusRoundEnded += game => + MelonLogger.Msg("Ride the Bus round ended"); +``` + +The same events are available on an individual `RideTheBusGame` wrapper. + +## Slot machines + +Slot wrappers expose their position, current bet, available native bets, and spinning state. Spin events include the bet, ordered reel symbols, whether the native spinner belongs to the current client, and the eventual outcome and win amount. + +```csharp +CasinoGameRegistry.SlotSpinStarted += (machine, spin) => + MelonLogger.Msg($"{machine.Name} started a ${spin.Bet} spin"); + +CasinoGameRegistry.SlotSpinCompleted += (machine, spin) => + MelonLogger.Msg($"{machine.Name}: {spin.Outcome}, won ${spin.WinAmount}"); +``` + +The same events are available on an individual `SlotMachine` wrapper. S1API's existing NPC slot-machine helper also publishes through these lifecycle events. + +## Event lifetime + +Static registry subscriptions belong to your mod and remain subscribed across scene changes. Unsubscribe them when your mod no longer needs them. S1API clears scene-object wrapper and active-spin state before scene transitions, so discovery never returns cached objects from a previous gameplay scene. diff --git a/S1API/docs/casino-slot-machines.md b/S1API/docs/casino-slot-machines.md index 3b3738ee..0efcc423 100644 --- a/S1API/docs/casino-slot-machines.md +++ b/S1API/docs/casino-slot-machines.md @@ -2,6 +2,10 @@ The S1API provides a modder-facing API for making NPCs interact with slot machines in the casino. This system handles cash management, animations, and outcome determination automatically. +For read-only player-facing blackjack, Ride the Bus, and slot-machine state and lifecycle events, see [Casino game state](casino-games.md). + +For discovery, including finding the nearest slot machine, use `CasinoGameRegistry`. The legacy `SlotMachineHelper.FindNearestSlotMachine` method remains available for compatibility but exposes a native game type and is obsolete for new mods. + ## Overview The slot machine system allows NPCs to: diff --git a/S1API/docs/toc.yml b/S1API/docs/toc.yml index 75c39150..0a712180 100644 --- a/S1API/docs/toc.yml +++ b/S1API/docs/toc.yml @@ -120,7 +120,9 @@ href: stations.md - name: UI href: ui.md - - name: Casino Slots + - name: Casino Games + href: casino-games.md + - name: NPC Casino Slots href: casino-slot-machines.md - name: Cartel href: cartel-system.md From 746c77559accb1ae0ab2d1c1bf14bcffae3de05d Mon Sep 17 00:00:00 2001 From: ifBars Date: Sat, 15 Aug 2026 03:48:21 -0700 Subject: [PATCH 2/2] fix(casino): address review feedback --- S1API.Tests/Casino/CasinoApiContractTests.cs | 71 +++++-- S1API/Casino/CasinoGameRegistry.cs | 70 ++----- S1API/Casino/CasinoGames.cs | 197 +++++++------------ S1API/Casino/CasinoSnapshots.cs | 6 + S1API/Internal/CasinoEventInvoker.cs | 65 ++++++ S1API/Internal/Patches/CasinoGamePatches.cs | 88 ++++++--- S1API/docs/casino-games.md | 4 +- 7 files changed, 274 insertions(+), 227 deletions(-) create mode 100644 S1API/Internal/CasinoEventInvoker.cs diff --git a/S1API.Tests/Casino/CasinoApiContractTests.cs b/S1API.Tests/Casino/CasinoApiContractTests.cs index 82818436..01b726b7 100644 --- a/S1API.Tests/Casino/CasinoApiContractTests.cs +++ b/S1API.Tests/Casino/CasinoApiContractTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Reflection; using S1API.Casino; @@ -20,9 +21,10 @@ public void NativeLifecyclePatchPointsExistInTargetRuntime() Assert.NotNull(typeof(S1Casino.RTBGameController).GetMethod( "set_CurrentStage", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); - Assert.NotNull(typeof(S1Casino.SlotMachine).GetMethod( - "RpcLogic___StartSpin_2659526290", - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); + MethodBase? slotStartMethod = global::S1API.Internal.Patches.CasinoGamePatches + .FindSlotStartLogicMethod(typeof(S1Casino.SlotMachine)); + Assert.NotNull(slotStartMethod); + Assert.StartsWith("RpcLogic___StartSpin_", slotStartMethod!.Name); Assert.NotNull(typeof(S1Casino.SlotMachine).GetMethod( "DisplayOutcome", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)); @@ -49,6 +51,7 @@ public void SnapshotReferenceTypesExposeNoPublicSetters(Type snapshotType) Assert.All( snapshotType.GetProperties(BindingFlags.Public | BindingFlags.Instance), property => Assert.Null(property.SetMethod)); + AssertPublicInstanceFieldsAreReadonly(snapshotType); Assert.Empty(snapshotType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)); } @@ -68,6 +71,7 @@ public void CasinoWrappersCannotBePubliclyConstructedOrMutated() Assert.All( wrapperType.GetProperties(BindingFlags.Public | BindingFlags.Instance), property => Assert.Null(property.SetMethod)); + AssertPublicInstanceFieldsAreReadonly(wrapperType); } } @@ -99,7 +103,12 @@ public void LegacyNativeSlotLookupRemainsAsAnObsoleteCompatibilityShim() modifiers: null)!; Assert.NotNull(method); - Assert.NotNull(method.GetCustomAttribute()); + ObsoleteAttribute obsolete = Assert.IsType( + method.GetCustomAttribute()); + Assert.False(obsolete.IsError); + EditorBrowsableAttribute editorBrowsable = Assert.IsType( + method.GetCustomAttribute()); + Assert.Equal(EditorBrowsableState.Never, editorBrowsable.State); Assert.Equal(typeof(S1Casino.SlotMachine), method.ReturnType); MethodInfo managedMethod = typeof(CasinoGameRegistry).GetMethod( @@ -141,26 +150,64 @@ public void PublicCasinoApiDoesNotExposeNativeCasinoTypes() private static IEnumerable GetExposedTypes(MemberInfo member) { + IEnumerable declaredTypes; switch (member) { case PropertyInfo property: - yield return property.PropertyType; + declaredTypes = new[] { property.PropertyType }; break; case FieldInfo field: - yield return field.FieldType; + declaredTypes = new[] { field.FieldType }; break; case EventInfo eventInfo when eventInfo.EventHandlerType != null: - yield return eventInfo.EventHandlerType; + declaredTypes = new[] { eventInfo.EventHandlerType }; break; case MethodInfo method: - yield return method.ReturnType; - foreach (ParameterInfo parameter in method.GetParameters()) - yield return parameter.ParameterType; + declaredTypes = new[] { method.ReturnType } + .Concat(method.GetParameters().Select(parameter => parameter.ParameterType)); break; case ConstructorInfo constructor: - foreach (ParameterInfo parameter in constructor.GetParameters()) - yield return parameter.ParameterType; + declaredTypes = constructor.GetParameters().Select(parameter => parameter.ParameterType); break; + default: + return Array.Empty(); + } + + return declaredTypes.SelectMany(ExpandCompositeType); + } + + private static IEnumerable ExpandCompositeType(Type root) + { + var pending = new Stack(); + var visited = new HashSet(); + pending.Push(root); + + while (pending.Count > 0) + { + Type current = pending.Pop(); + if (!visited.Add(current)) + continue; + + yield return current; + + if (current.HasElementType && current.GetElementType() is Type elementType) + pending.Push(elementType); + + foreach (Type argument in current.GetGenericArguments()) + pending.Push(argument); + + if (current.BaseType != null) + pending.Push(current.BaseType); + + foreach (Type implementedInterface in current.GetInterfaces()) + pending.Push(implementedInterface); } } + + private static void AssertPublicInstanceFieldsAreReadonly(Type type) + { + Assert.All( + type.GetFields(BindingFlags.Public | BindingFlags.Instance), + field => Assert.True(field.IsInitOnly, $"{type.Name}.{field.Name} must be readonly.")); + } } diff --git a/S1API/Casino/CasinoGameRegistry.cs b/S1API/Casino/CasinoGameRegistry.cs index 678dd879..5365ac29 100644 --- a/S1API/Casino/CasinoGameRegistry.cs +++ b/S1API/Casino/CasinoGameRegistry.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using S1API.Internal; using S1API.Lifecycle; using S1API.Logging; using UnityEngine; @@ -53,7 +54,7 @@ public static class CasinoGameRegistry /// Raised when a slot machine displays a synchronized spin outcome. public static event Action? SlotSpinCompleted; - /// Gets immutable wrappers for all active blackjack tables. + /// Gets a read-only snapshot of all active blackjack tables. public static IReadOnlyList GetBlackjackGames() { EnsureLifecycleHook(); @@ -67,7 +68,7 @@ public static IReadOnlyList GetBlackjackGames() return new ReadOnlyCollection(games); } - /// Gets immutable wrappers for all active Ride the Bus tables. + /// Gets a read-only snapshot of all active Ride the Bus tables. public static IReadOnlyList GetRideTheBusGames() { EnsureLifecycleHook(); @@ -81,7 +82,7 @@ public static IReadOnlyList GetRideTheBusGames() return new ReadOnlyCollection(games); } - /// Gets immutable wrappers for all active native slot machines. + /// Gets a read-only snapshot of all active native slot machines. public static IReadOnlyList GetSlotMachines() { EnsureLifecycleHook(); @@ -105,7 +106,7 @@ public static IReadOnlyList GetSlotMachines() return native == null ? null : Wrap(native); } - /// Gets immutable wrappers for all active blackjack and Ride the Bus tables. + /// Gets a read-only snapshot of all active blackjack and Ride the Bus tables. public static IReadOnlyList GetTables() { var tables = new List(); @@ -160,12 +161,12 @@ internal static void NotifyBlackjackStageChanged( BlackjackGame game = Wrap(native); game.NotifyStageChanged(previous, current); - InvokeSafely(BlackjackStageChanged, game, previous, current, nameof(BlackjackStageChanged)); + CasinoEventInvoker.Invoke(BlackjackStageChanged, game, previous, current, nameof(BlackjackStageChanged)); if (current == BlackjackStage.Dealing && previous == BlackjackStage.WaitingForPlayers) - InvokeSafely(BlackjackRoundStarted, game, nameof(BlackjackRoundStarted)); + CasinoEventInvoker.Invoke(BlackjackRoundStarted, game, nameof(BlackjackRoundStarted)); if (current == BlackjackStage.WaitingForPlayers) - InvokeSafely(BlackjackRoundEnded, game, nameof(BlackjackRoundEnded)); + CasinoEventInvoker.Invoke(BlackjackRoundEnded, game, nameof(BlackjackRoundEnded)); } internal static void NotifyRideTheBusStageChanged( @@ -178,12 +179,12 @@ internal static void NotifyRideTheBusStageChanged( RideTheBusGame game = Wrap(native); game.NotifyStageChanged(previous, current); - InvokeSafely(RideTheBusStageChanged, game, previous, current, nameof(RideTheBusStageChanged)); + CasinoEventInvoker.Invoke(RideTheBusStageChanged, game, previous, current, nameof(RideTheBusStageChanged)); if (current == RideTheBusStage.RedOrBlack && previous == RideTheBusStage.WaitingForPlayers) - InvokeSafely(RideTheBusRoundStarted, game, nameof(RideTheBusRoundStarted)); + CasinoEventInvoker.Invoke(RideTheBusRoundStarted, game, nameof(RideTheBusRoundStarted)); if (current == RideTheBusStage.WaitingForPlayers) - InvokeSafely(RideTheBusRoundEnded, game, nameof(RideTheBusRoundEnded)); + CasinoEventInvoker.Invoke(RideTheBusRoundEnded, game, nameof(RideTheBusRoundEnded)); } internal static void NotifySlotSpinStarted( @@ -195,7 +196,7 @@ internal static void NotifySlotSpinStarted( SlotMachine machine = Wrap(native); machine.NotifySpinStarted(snapshot); - InvokeSafely(SlotSpinStarted, machine, snapshot, nameof(SlotSpinStarted)); + CasinoEventInvoker.Invoke(SlotSpinStarted, machine, snapshot, nameof(SlotSpinStarted)); } internal static void NotifySlotSpinCompleted( @@ -211,7 +212,7 @@ internal static void NotifySlotSpinCompleted( SlotSpinSnapshot completed = started.Complete(outcome, winAmount); SlotMachine machine = Wrap(native); machine.NotifySpinCompleted(completed); - InvokeSafely(SlotSpinCompleted, machine, completed, nameof(SlotSpinCompleted)); + CasinoEventInvoker.Invoke(SlotSpinCompleted, machine, completed, nameof(SlotSpinCompleted)); } internal static void LogSubscriberFailure(string eventName, Exception exception) => @@ -233,50 +234,5 @@ private static void ClearSceneState() SlotMachines.Clear(); ActiveSpins.Clear(); } - - private static void InvokeSafely(Action? handlers, T value, string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(value); } - catch (Exception ex) { LogSubscriberFailure(eventName, ex); } - } - } - - private static void InvokeSafely( - Action? handlers, - T1 value1, - T2 value2, - string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(value1, value2); } - catch (Exception ex) { LogSubscriberFailure(eventName, ex); } - } - } - - private static void InvokeSafely( - Action? handlers, - T1 value1, - T2 value2, - T3 value3, - string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(value1, value2, value3); } - catch (Exception ex) { LogSubscriberFailure(eventName, ex); } - } - } } } diff --git a/S1API/Casino/CasinoGames.cs b/S1API/Casino/CasinoGames.cs index 5cead3eb..951da1fb 100644 --- a/S1API/Casino/CasinoGames.cs +++ b/S1API/Casino/CasinoGames.cs @@ -12,7 +12,9 @@ using System.Linq; using System.Reflection; using S1API.Entities; +using S1API.Internal; using S1API.Internal.Utils; +using S1API.Logging; using UnityEngine; namespace S1API.Casino @@ -24,37 +26,38 @@ public abstract class CasinoGameTable { private static readonly IReadOnlyList EmptyPlayers = new ReadOnlyCollection(Array.Empty()); + private static readonly Log Logger = new Log("CasinoGameTable"); internal CasinoGameTable(S1Casino.CasinoGameController controller) { - Controller = controller ?? throw new ArgumentNullException(nameof(controller)); + S1Controller = controller ?? throw new ArgumentNullException(nameof(controller)); } - internal S1Casino.CasinoGameController Controller { get; } + internal S1Casino.CasinoGameController S1Controller { get; } /// Gets the scene object name. - public string Name => Controller.gameObject?.name ?? string.Empty; + public string Name => S1Controller.gameObject?.name ?? string.Empty; /// Gets the current world position. - public Vector3 Position => Controller.transform.position; + public Vector3 Position => S1Controller.transform.position; /// /// Gets whether this table's interface is open for the local player. /// - public bool IsOpen => Controller.IsOpen; + public bool IsOpen => S1Controller.IsOpen; /// Gets whether the table is currently accepting ready players. - public bool IsWaitingForPlayers => Controller.IsWaitingForPlayers(); + public bool IsWaitingForPlayers => S1Controller.IsWaitingForPlayers(); /// Gets the local player's currently selected bet. - public float LocalBet => Controller.LocalPlayerBet; + public float LocalBet => S1Controller.LocalPlayerBet; /// Gets the native minimum and maximum bet. public CasinoBetLimits BetLimits { get { - Controller.GetBetLimits(out float minimum, out float maximum); + S1Controller.GetBetLimits(out float minimum, out float maximum); return new CasinoBetLimits(minimum, maximum); } } @@ -66,7 +69,7 @@ public IReadOnlyList Players { get { - S1Casino.CasinoGamePlayers? players = Controller.Players; + S1Casino.CasinoGamePlayers? players = S1Controller.Players; if (players == null) return EmptyPlayers; @@ -83,8 +86,9 @@ public IReadOnlyList Players S1Casino.CasinoGamePlayerData? data = players.GetPlayerData(nativePlayer); ready = data != null && data.GetData("Ready"); } - catch + catch (Exception ex) { + Logger.Warning($"Failed to read ready state for player '{nativePlayer.PlayerName}': {ex.Message}"); } snapshots.Add(new CasinoPlayerSnapshot( @@ -112,14 +116,27 @@ public sealed class BlackjackGame : CasinoGameTable { private static readonly IReadOnlyList EmptyCards = new ReadOnlyCollection(Array.Empty()); +#if MONOMELON + private const BindingFlags NativeMemberFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private static readonly MethodInfo? GetPlayerCardsMethod = + typeof(S1Casino.BlackjackGameController).GetMethod( + "GetPlayerCards", + NativeMemberFlags, + binder: null, + types: new[] { typeof(int) }, + modifiers: null); + private static readonly FieldInfo? DealerHandField = + typeof(S1Casino.BlackjackGameController).GetField("dealerHand", NativeMemberFlags); +#endif internal BlackjackGame(S1Casino.BlackjackGameController controller) : base(controller) { - Native = controller; + S1Native = controller; } - internal S1Casino.BlackjackGameController Native { get; } + internal S1Casino.BlackjackGameController S1Native { get; } /// Raised after the native blackjack stage changes. public event Action? StageChanged; @@ -131,25 +148,25 @@ internal BlackjackGame(S1Casino.BlackjackGameController controller) public event Action? RoundEnded; /// Gets the current round stage. - public BlackjackStage Stage => (BlackjackStage)(int)Native.CurrentStage; + public BlackjackStage Stage => (BlackjackStage)(int)S1Native.CurrentStage; /// Gets the current dealer score visible to this peer. - public int DealerScore => Native.DealerScore; + public int DealerScore => S1Native.DealerScore; /// Gets the current local-player score. - public int LocalPlayerScore => Native.LocalPlayerScore; + public int LocalPlayerScore => S1Native.LocalPlayerScore; /// Gets whether the local player has a natural blackjack. - public bool IsLocalPlayerBlackjack => Native.IsLocalPlayerBlackjack; + public bool IsLocalPlayerBlackjack => S1Native.IsLocalPlayerBlackjack; /// Gets whether the local player is bust. - public bool IsLocalPlayerBust => Native.IsLocalPlayerBust; + public bool IsLocalPlayerBust => S1Native.IsLocalPlayerBust; /// Gets whether the local player belongs to the active round. - public bool IsLocalPlayerInRound => Native.IsLocalPlayerInCurrentRound; + public bool IsLocalPlayerInRound => S1Native.IsLocalPlayerInCurrentRound; /// Gets the number of seated players currently marked ready. - public int ReadyPlayerCount => Native.GetPlayersReadyCount(); + public int ReadyPlayerCount => S1Native.GetPlayersReadyCount(); /// /// Gets an immutable snapshot of a seated player's current hand. @@ -157,16 +174,13 @@ internal BlackjackGame(S1Casino.BlackjackGameController controller) /// The zero-based seat index. public IReadOnlyList GetPlayerHand(int seatIndex) { - if (seatIndex < 0 || seatIndex >= Native.Players.PlayerLimit) + if (seatIndex < 0 || seatIndex >= S1Native.Players.PlayerLimit) return EmptyCards; #if IL2CPPMELON - var cards = Native.GetPlayerCards(seatIndex); + var cards = S1Native.GetPlayerCards(seatIndex); #else - var method = typeof(S1Casino.BlackjackGameController).GetMethod( - "GetPlayerCards", - BindingFlags.Instance | BindingFlags.NonPublic); - var cards = method?.Invoke(Native, new object[] { seatIndex }) + var cards = GetPlayerCardsMethod?.Invoke(S1Native, new object[] { seatIndex }) as List; #endif return FreezeCards(cards); @@ -178,12 +192,9 @@ public IReadOnlyList DealerHand get { #if IL2CPPMELON - var cards = Native.dealerHand; + var cards = S1Native.dealerHand; #else - var dealerHandField = typeof(S1Casino.BlackjackGameController).GetField( - "dealerHand", - BindingFlags.Instance | BindingFlags.NonPublic); - var cards = dealerHandField?.GetValue(Native) as List; + var cards = DealerHandField?.GetValue(S1Native) as List; #endif return FreezeCards(cards); } @@ -191,11 +202,11 @@ public IReadOnlyList DealerHand internal void NotifyStageChanged(BlackjackStage previous, BlackjackStage current) { - InvokeSafely(StageChanged, previous, current, nameof(StageChanged)); + CasinoEventInvoker.Invoke(StageChanged, previous, current, nameof(StageChanged)); if (current == BlackjackStage.Dealing && previous == BlackjackStage.WaitingForPlayers) - InvokeSafely(RoundStarted, nameof(RoundStarted)); + CasinoEventInvoker.Invoke(RoundStarted, nameof(RoundStarted)); if (current == BlackjackStage.WaitingForPlayers && previous != current) - InvokeSafely(RoundEnded, nameof(RoundEnded)); + CasinoEventInvoker.Invoke(RoundEnded, nameof(RoundEnded)); } private static IReadOnlyList FreezeCards( @@ -227,34 +238,6 @@ internal static CasinoCardSnapshot ToSnapshot(S1Casino.PlayingCard card) => (CasinoCardSuit)(int)card.Suit, (CasinoCardValue)(int)card.Value, card.IsFaceUp); - - private static void InvokeSafely(Action? handlers, string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(); } - catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } - } - } - - private static void InvokeSafely( - Action? handlers, - BlackjackStage previous, - BlackjackStage current, - string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(previous, current); } - catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } - } - } } /// @@ -268,10 +251,10 @@ public sealed class RideTheBusGame : CasinoGameTable internal RideTheBusGame(S1Casino.RTBGameController controller) : base(controller) { - Native = controller; + S1Native = controller; } - internal S1Casino.RTBGameController Native { get; } + internal S1Casino.RTBGameController S1Native { get; } /// Raised after the native Ride the Bus stage changes. public event Action? StageChanged; @@ -283,41 +266,42 @@ internal RideTheBusGame(S1Casino.RTBGameController controller) public event Action? RoundEnded; /// Gets the current round stage. - public RideTheBusStage Stage => (RideTheBusStage)(int)Native.CurrentStage; + public RideTheBusStage Stage => (RideTheBusStage)(int)S1Native.CurrentStage; /// Gets whether a question is currently accepting answers. - public bool IsQuestionActive => Native.IsQuestionActive; + public bool IsQuestionActive => S1Native.IsQuestionActive; /// Gets the local player's current bet multiplier. - public float LocalBetMultiplier => Native.LocalPlayerBetMultiplier; + public float LocalBetMultiplier => S1Native.LocalPlayerBetMultiplier; /// Gets the local player's multiplied bet. - public float MultipliedLocalBet => Native.MultipliedLocalPlayerBet; + public float MultipliedLocalBet => S1Native.MultipliedLocalPlayerBet; /// Gets the answer time remaining on the current peer. - public float RemainingAnswerTime => Native.RemainingAnswerTime; + public float RemainingAnswerTime => S1Native.RemainingAnswerTime; /// Gets whether the local player belongs to the active round. - public bool IsLocalPlayerInRound => Native.IsLocalPlayerInCurrentRound; + public bool IsLocalPlayerInRound => S1Native.IsLocalPlayerInCurrentRound; /// Gets the number of seated players currently marked ready. - public int ReadyPlayerCount => Native.GetPlayersReadyCount(); + public int ReadyPlayerCount => S1Native.GetPlayersReadyCount(); /// Gets the number of active-round players who submitted an answer. - public int AnsweredPlayerCount => Native.GetAnsweredPlayersCount(); + public int AnsweredPlayerCount => S1Native.GetAnsweredPlayersCount(); /// Gets immutable snapshots of cards currently assigned by the table. public IReadOnlyList Cards { get { - if (Native.Cards == null || Native.Cards.Length == 0) + var nativeCards = S1Native.Cards; + if (nativeCards == null || nativeCards.Length == 0) return EmptyCards; var cards = new List(); - for (int i = 0; i < Native.Cards.Length; i++) + for (int i = 0; i < nativeCards.Length; i++) { - S1Casino.PlayingCard? card = Native.Cards[i]; + S1Casino.PlayingCard? card = nativeCards[i]; if (card != null && (int)card.Value != (int)CasinoCardValue.Blank) cards.Add(BlackjackGame.ToSnapshot(card)); } @@ -330,39 +314,11 @@ public IReadOnlyList Cards internal void NotifyStageChanged(RideTheBusStage previous, RideTheBusStage current) { - InvokeSafely(StageChanged, previous, current, nameof(StageChanged)); + CasinoEventInvoker.Invoke(StageChanged, previous, current, nameof(StageChanged)); if (current == RideTheBusStage.RedOrBlack && previous == RideTheBusStage.WaitingForPlayers) - InvokeSafely(RoundStarted, nameof(RoundStarted)); + CasinoEventInvoker.Invoke(RoundStarted, nameof(RoundStarted)); if (current == RideTheBusStage.WaitingForPlayers && previous != current) - InvokeSafely(RoundEnded, nameof(RoundEnded)); - } - - private static void InvokeSafely(Action? handlers, string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(); } - catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } - } - } - - private static void InvokeSafely( - Action? handlers, - RideTheBusStage previous, - RideTheBusStage current, - string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(previous, current); } - catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } - } + CasinoEventInvoker.Invoke(RoundEnded, nameof(RoundEnded)); } } @@ -373,10 +329,10 @@ public sealed class SlotMachine { internal SlotMachine(S1Casino.SlotMachine machine) { - Native = machine ?? throw new ArgumentNullException(nameof(machine)); + S1Native = machine ?? throw new ArgumentNullException(nameof(machine)); } - internal S1Casino.SlotMachine Native { get; } + internal S1Casino.SlotMachine S1Native { get; } /// Raised when a synchronized spin begins. public event Action? SpinStarted; @@ -385,20 +341,20 @@ internal SlotMachine(S1Casino.SlotMachine machine) public event Action? SpinCompleted; /// Gets the scene object name. - public string Name => Native.gameObject?.name ?? string.Empty; + public string Name => S1Native.gameObject?.name ?? string.Empty; /// Gets the current world position. - public Vector3 Position => Native.transform.position; + public Vector3 Position => S1Native.transform.position; /// Gets whether the reels are currently spinning. - public bool IsSpinning => Native.IsSpinning; + public bool IsSpinning => S1Native.IsSpinning; /// Gets the machine's currently selected bet. public int CurrentBet { get { - object? value = ReflectionUtils.TryGetFieldOrProperty(Native, "currentBetAmount"); + object? value = ReflectionUtils.TryGetFieldOrProperty(S1Native, "currentBetAmount"); return value is int bet ? bet : 0; } } @@ -417,24 +373,9 @@ public IReadOnlyList AvailableBets } internal void NotifySpinStarted(SlotSpinSnapshot snapshot) => - InvokeSafely(SpinStarted, snapshot, nameof(SpinStarted)); + CasinoEventInvoker.Invoke(SpinStarted, snapshot, nameof(SpinStarted)); internal void NotifySpinCompleted(SlotSpinSnapshot snapshot) => - InvokeSafely(SpinCompleted, snapshot, nameof(SpinCompleted)); - - private static void InvokeSafely( - Action? handlers, - SlotSpinSnapshot snapshot, - string eventName) - { - if (handlers == null) - return; - - foreach (Action handler in handlers.GetInvocationList()) - { - try { handler(snapshot); } - catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } - } - } + CasinoEventInvoker.Invoke(SpinCompleted, snapshot, nameof(SpinCompleted)); } } diff --git a/S1API/Casino/CasinoSnapshots.cs b/S1API/Casino/CasinoSnapshots.cs index c1b338f9..14564f42 100644 --- a/S1API/Casino/CasinoSnapshots.cs +++ b/S1API/Casino/CasinoSnapshots.cs @@ -13,6 +13,8 @@ public readonly struct CasinoBetLimits /// /// Creates an immutable bet-limit snapshot. /// + /// The minimum bet accepted by the table. + /// The maximum bet accepted by the table. public CasinoBetLimits(float minimum, float maximum) { Minimum = minimum; @@ -84,6 +86,10 @@ public readonly struct CasinoCardSnapshot /// /// Creates an immutable card snapshot. /// + /// The scene-local native card identifier. + /// The card suit. + /// The card value. + /// Whether the card is face up for the current client. public CasinoCardSnapshot(string id, CasinoCardSuit suit, CasinoCardValue value, bool isFaceUp) { Id = id ?? string.Empty; diff --git a/S1API/Internal/CasinoEventInvoker.cs b/S1API/Internal/CasinoEventInvoker.cs new file mode 100644 index 00000000..cb38196e --- /dev/null +++ b/S1API/Internal/CasinoEventInvoker.cs @@ -0,0 +1,65 @@ +using System; +using S1API.Casino; + +namespace S1API.Internal +{ + internal static class CasinoEventInvoker + { + internal static void Invoke(Action? handlers, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + internal static void Invoke(Action? handlers, T value, string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + internal static void Invoke( + Action? handlers, + T1 value1, + T2 value2, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value1, value2); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + + internal static void Invoke( + Action? handlers, + T1 value1, + T2 value2, + T3 value3, + string eventName) + { + if (handlers == null) + return; + + foreach (Action handler in handlers.GetInvocationList()) + { + try { handler(value1, value2, value3); } + catch (Exception ex) { CasinoGameRegistry.LogSubscriberFailure(eventName, ex); } + } + } + } +} diff --git a/S1API/Internal/Patches/CasinoGamePatches.cs b/S1API/Internal/Patches/CasinoGamePatches.cs index 5164e3e1..89798c74 100644 --- a/S1API/Internal/Patches/CasinoGamePatches.cs +++ b/S1API/Internal/Patches/CasinoGamePatches.cs @@ -7,7 +7,10 @@ using S1NetworkConnection = FishNet.Connection.NetworkConnection; #endif +using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using HarmonyLib; using S1API.Casino; @@ -63,41 +66,68 @@ private static void RideTheBusStagePostfix( (RideTheBusStage)(int)__0); } - [HarmonyPatch(typeof(S1Casino.SlotMachine), "RpcLogic___StartSpin_2659526290")] - [HarmonyPrefix] - private static void SlotSpinPrefix( - S1Casino.SlotMachine __instance, - S1NetworkConnection __0, + internal static MethodBase? FindSlotStartLogicMethod(Type slotMachineType) + { + return slotMachineType + .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .FirstOrDefault(method => + { + if (!method.Name.StartsWith("RpcLogic___StartSpin_", StringComparison.Ordinal)) + return false; + + ParameterInfo[] parameters = method.GetParameters(); + return method.ReturnType == typeof(void) + && parameters.Length == 3 + && parameters[0].ParameterType == typeof(S1NetworkConnection) #if IL2CPPMELON - Il2CppStructArray __1, + && parameters[1].ParameterType == typeof(Il2CppStructArray) #else - S1Casino.SlotMachine.ESymbol[] __1, + && parameters[1].ParameterType == typeof(S1Casino.SlotMachine.ESymbol[]) #endif - int __2, - out SlotStartPatchState __state) - { - var symbols = new List(__1.Length); - for (int i = 0; i < __1.Length; i++) - symbols.Add((SlotSymbol)(int)__1[i]); - - __state = new SlotStartPatchState( - __instance.IsSpinning, - new SlotSpinSnapshot( - __2, - SlotSpinSnapshot.Freeze(symbols), - __0 != null && __0.IsLocalClient, - outcome: null, - winAmount: null)); + && parameters[2].ParameterType == typeof(int); + }); } - [HarmonyPatch(typeof(S1Casino.SlotMachine), "RpcLogic___StartSpin_2659526290")] - [HarmonyPostfix] - private static void SlotSpinPostfix( - S1Casino.SlotMachine __instance, - SlotStartPatchState __state) + [HarmonyPatch] + private static class SlotSpinPatch { - if (!__state.WasSpinning && __instance.IsSpinning) - CasinoGameRegistry.NotifySlotSpinStarted(__instance, __state.Snapshot); + private static MethodBase? TargetMethod() => + FindSlotStartLogicMethod(typeof(S1Casino.SlotMachine)); + + [HarmonyPrefix] + private static void Prefix( + S1Casino.SlotMachine __instance, + S1NetworkConnection __0, +#if IL2CPPMELON + Il2CppStructArray __1, +#else + S1Casino.SlotMachine.ESymbol[] __1, +#endif + int __2, + out SlotStartPatchState __state) + { + var symbols = new List(__1.Length); + for (int i = 0; i < __1.Length; i++) + symbols.Add((SlotSymbol)(int)__1[i]); + + __state = new SlotStartPatchState( + __instance.IsSpinning, + new SlotSpinSnapshot( + __2, + SlotSpinSnapshot.Freeze(symbols), + __0 != null && __0.IsLocalClient, + outcome: null, + winAmount: null)); + } + + [HarmonyPostfix] + private static void Postfix( + S1Casino.SlotMachine __instance, + SlotStartPatchState __state) + { + if (!__state.WasSpinning && __instance.IsSpinning) + CasinoGameRegistry.NotifySlotSpinStarted(__instance, __state.Snapshot); + } } [HarmonyPatch(typeof(S1Casino.SlotMachine), "DisplayOutcome")] diff --git a/S1API/docs/casino-games.md b/S1API/docs/casino-games.md index de454d48..3aaafaa4 100644 --- a/S1API/docs/casino-games.md +++ b/S1API/docs/casino-games.md @@ -30,7 +30,7 @@ if (nearest != null) MelonLogger.Msg($"Nearest slot machine: {nearest.Name} at {nearest.Position}"); ``` -Registry results are immutable snapshots. Calling a discovery method again reflects the objects in the current scene, while wrappers for the same live scene object retain their identity so instance event subscriptions remain attached. +Registry results are read-only snapshots of the active casino objects at the time of discovery. Their wrapper instances represent live scene objects, so wrapper properties continue to reflect current controller state. Calling a discovery method again reflects the active objects in the current scene, while wrappers for the same live scene object retain their identity within that scene. ## Shared table state @@ -114,3 +114,5 @@ The same events are available on an individual `SlotMachine` wrapper. S1API's ex ## Event lifetime Static registry subscriptions belong to your mod and remain subscribed across scene changes. Unsubscribe them when your mod no longer needs them. S1API clears scene-object wrapper and active-spin state before scene transitions, so discovery never returns cached objects from a previous gameplay scene. + +Subscriptions attached directly to a `BlackjackGame`, `RideTheBusGame`, or `SlotMachine` wrapper last only for that wrapper's scene. Query and subscribe to new wrappers after each gameplay scene load, or use the static `CasinoGameRegistry` events when the subscription should remain active across scene changes.