diff --git a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs new file mode 100644 index 0000000..4233c67 --- /dev/null +++ b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs @@ -0,0 +1,22 @@ +using NUnit.Framework; +using SVSim.BattleNode.Sessions.Engine; +using System.Linq; + +namespace SVSim.BattleEngine.Tests.SessionEngine; + +[TestFixture] +public class SessionEngineSpellboostTests +{ + [Test] + public void EngineGlobalInit_makes_a_fresh_engine_ready() + { + EngineGlobalInit.EnsureInitialized(); + var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson"); + var cl2 = CaptureReplay.Load("battle_test_cl2.ndjson"); + var deckA = CaptureReplay.SelfDeckFrom(cl1); + var deckB = CaptureReplay.SelfDeckFrom(cl2); + var engine = new SessionBattleEngine(); + Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB)); + Assert.That(engine.IsReady, Is.True, "engine must be ready after EngineGlobalInit (carried-risk fix)"); + } +} diff --git a/SVSim.BattleNode/SVSim.BattleNode.csproj b/SVSim.BattleNode/SVSim.BattleNode.csproj index fe0fa9a..4816d9b 100644 --- a/SVSim.BattleNode/SVSim.BattleNode.csproj +++ b/SVSim.BattleNode/SVSim.BattleNode.csproj @@ -22,4 +22,9 @@ + + + + diff --git a/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs new file mode 100644 index 0000000..6f386c8 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Engine/EngineGlobalInit.cs @@ -0,0 +1,293 @@ +extern alias engine; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using BattleManagerBase = engine::BattleManagerBase; +using CardCSVData = engine::Wizard.CardCSVData; +using CardMaster = engine::Wizard.CardMaster; +using Certification = engine::Cute.Certification; +using ClassCharacterMasterData = engine::Wizard.ClassCharacterMasterData; +using Crossover = engine::Wizard.Crossover; +using Data = engine::Wizard.Data; +using GameMgr = engine::GameMgr; +using Load = engine::Load; +using LoadDetail = engine::LoadDetail; +using Master = engine::Wizard.Master; +using NetworkUserInfoData = engine::NetworkUserInfoData; + +namespace SVSim.BattleNode.Sessions.Engine; + +/// Host-owned, process-once initializer for the engine's global statics (Phase 2 N2, +/// carried-risk A). The decompiled engine assumes a set of process-globals exist that the client +/// populates from /load/index at login: the static CardMaster, Wizard.Data +/// (Load/Master/Crossover), the GameMgr DataMgr chara ids, a NetworkUserInfoData, and +/// Cute.Certification.udid. Without them throws inside +/// its try/catch and the shadow silently no-ops (the N1 carried risk). Calling +/// once at host startup primes them so Setup succeeds. +/// +/// This is the production analogue of the test fixtures HeadlessEngineEnv.EnsureInitialized + +/// HeadlessCardMaster + HeadlessMasterData; the reflection seams are transcribed verbatim. +/// It differs in exactly three ways: (1) it loads the FULL cards.json (every row, no id filter) since +/// the live host serves arbitrary decks; (2) it installs ALL 8 classes in ClassCharacterList; and +/// (3) every set is guarded so the call is idempotent AND does not fight the test +/// HeadlessEngineEnv if both run in one NUnit process. +internal static class EngineGlobalInit +{ + private static readonly object _gate = new(); + private static bool _done; + + private static readonly string CardsJsonPath = + Path.Combine(AppContext.BaseDirectory, "Data", "cards.json"); + + // chara ids -> a ClassCharacterMasterData in Master; mirrors HeadlessMasterData. + private const int PlayerCharaId = 1; + private const int EnemyCharaId = 2; + + public static void EnsureInitialized() + { + if (_done) return; + lock (_gate) + { + if (_done) return; + + // --- Wizard.Data globals (the static /load/index snapshot) ----------------------------- + // The mgr ctor's CreateBackgroundId reads Data.Load.data._userTutorial (LoadDetail + // self-inits _userTutorial). ??= so we don't clobber a snapshot HeadlessEngineEnv set. + Data.Load ??= new Load { data = new LoadDetail() }; + + // CardParameter(CardCSVData) reads Data.Crossover.RestrictedCard for the deck-limit calc; + // an empty Crossover returns the default count (no restriction). Private setter -> reflect. + // Only set when null so we coexist with HeadlessEngineEnv. + if (Data.Crossover == null) + { + typeof(Data).GetProperty("Crossover", + BindingFlags.Static | BindingFlags.Public)! + .SetValue(null, new Crossover()); + } + + // Suppress VFX / take the virtual-battle resolution path (no live view layer). + BattleManagerBase.IsForecast = true; + + // --- static CardMaster (full cards.json) ---------------------------------------------- + // Skip the (expensive) full load if a CardMaster is already registered as Default — a + // prior EngineGlobalInit call OR a HeadlessEngineEnv/HeadlessCardMaster load in the same + // NUnit process already populated it. + if (!IsCardMasterPopulated()) + LoadFullCardMaster(); + + // --- Master reference data (all 8 classes' chara list) --------------------------------- + // Skip if Data.Master is already non-null with a non-empty ClassCharacterList. + if (!IsMasterPopulated()) + InstallMaster(); + + // --- GameMgr DataMgr leader chara ids -------------------------------------------------- + // Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation/ + // AvatarBattle info (more null statics) the resolution path doesn't need. Idempotent + // (plain assignment); only meaningful when still 0. + var dm = GameMgr.GetIns().GetDataMgr(); + SetFieldIfZeroOrNull(dm, "_playerCharaId", PlayerCharaId); + SetFieldIfZeroOrNull(dm, "_enemyCharaId", EnemyCharaId); + + // --- NetworkUserInfoData (background lookup on the network mgr's CreateBackgroundId) ---- + // NetworkBattleManagerBase.CreateBackgroundId reads + // GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no + // bg id. GameMgr leaves _netUser null with no lazy init; seed a no-op instance whose + // _selfInfo carries just fieldId=1 (== ForestField, a valid background). Only seed when + // absent so a HeadlessEngineEnv-set instance is preserved. + if (GameMgr.GetIns().GetNetworkUserInfoData() == null) + { + var netUser = new NetworkUserInfoData(); + netUser.SetSelfInfo( + new Dictionary { ["fieldId"] = 1 }, + isWatchReplayRecovery: false); + GameMgr.GetIns().SetNetworkUserInfoData(netUser); + } + + // --- Cute.Certification.udid ----------------------------------------------------------- + // The emit-path payload builder reads Certification.Udid, whose getter lazily decodes from + // Toolbox.SavedataManager (null headless). Seed the private static backing field with a + // non-empty placeholder so the getter short-circuits. Only set when empty (coexistence). + var udidField = typeof(Certification).GetField("udid", + BindingFlags.Static | BindingFlags.NonPublic)!; + if (string.IsNullOrEmpty(udidField.GetValue(null) as string)) + udidField.SetValue(null, "host-udid"); + + _done = true; + } + } + + // --- CardMaster (full load) ---------------------------------------------------------------------- + + private static bool IsCardMasterPopulated() + { + var idType = typeof(CardMaster).GetNestedType("CardMasterId")!; + var defaultId = Enum.Parse(idType, "Default"); + var fld = typeof(CardMaster).GetField("_dictCardMaster", + BindingFlags.Static | BindingFlags.NonPublic)!; + if (fld.GetValue(null) is not IDictionary dict) return false; + return dict.Contains(defaultId) && dict[defaultId] != null; + } + + // Production difference (1): enumerate EVERY card row — no want.Contains(id) filter. + private static void LoadFullCardMaster() + { + var rows = new List(); + using (var doc = JsonDocument.Parse(File.ReadAllText(CardsJsonPath))) + { + int sort = 0; + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (!el.TryGetProperty("card_id", out var idEl)) continue; + if (!int.TryParse(idEl.GetString(), out _)) continue; // skip malformed ids + rows.Add(BuildCardCsvData(el, sort++)); + } + } + + var cm = NewCardMaster(rows); + InjectAsDefault(cm); + } + + // Transcribed from HeadlessCardMaster.BuildCardCsvData. + private static CardCSVData BuildCardCsvData(JsonElement el, int sortIndex) + { + var c = (CardCSVData)FormatterServices.GetUninitializedObject(typeof(CardCSVData)); + const BindingFlags bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + foreach (var prop in el.EnumerateObject()) + { + string val = prop.Value.ValueKind == JsonValueKind.Null ? null : prop.Value.ToString(); + var f = typeof(CardCSVData).GetField(prop.Name, bf); + if (f != null) { SetMember(f.FieldType, val, v => f.SetValue(c, v)); continue; } + var p = typeof(CardCSVData).GetProperty(prop.Name, bf); + if (p != null && p.CanWrite) SetMember(p.PropertyType, val, v => p.SetValue(c, v)); + } + var si = typeof(CardCSVData).GetProperty("SortIndex", bf); + if (si != null && si.CanWrite) si.SetValue(c, sortIndex); + return c; + } + + private static void SetMember(Type t, string val, Action set) + { + if (t == typeof(string)) set(val); + else if (t == typeof(int)) set(int.TryParse(val, out var i) ? i : 0); + else if (t == typeof(bool)) set(val == "1" || string.Equals(val, "true", StringComparison.OrdinalIgnoreCase)); + // other types left at default + } + + private static CardMaster NewCardMaster(List rows) + { + var ctor = typeof(CardMaster).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, null, + new[] { typeof(List) }, null); + if (ctor == null) throw new InvalidOperationException("CardMaster(List) ctor not found"); + return (CardMaster)ctor.Invoke(new object[] { rows }); + } + + private static void InjectAsDefault(CardMaster cm) + { + var idType = typeof(CardMaster).GetNestedType("CardMasterId"); + var defaultId = Enum.Parse(idType, "Default"); + var dictType = typeof(Dictionary<,>).MakeGenericType(idType, typeof(CardMaster)); + var dict = (IDictionary)Activator.CreateInstance(dictType); + dict[defaultId] = cm; + var fld = typeof(CardMaster).GetField("_dictCardMaster", + BindingFlags.Static | BindingFlags.NonPublic); + fld.SetValue(null, dict); + } + + // --- Master reference data (all 8 classes) ------------------------------------------------------- + + private static bool IsMasterPopulated() + { + if (Data.Master is not Master m) return false; + var p = typeof(Master).GetProperty("ClassCharacterList", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + return p?.GetValue(m) is ICollection { Count: > 0 }; + } + + // Transcribed from HeadlessMasterData.Install. Production difference (2): install ALL 8 classes. + private static void InstallMaster() + { + var master = (Master)FormatterServices.GetUninitializedObject(typeof(Master)); + EnsureEmptyCollections(master); + var list = new List(); + for (int c = 1; c <= 8; c++) + list.Add(NewChara(c, c)); // charaId == classId for class c + SetMember(master, "ClassCharacterList", list); + Data.Master = master; + } + + private static void EnsureEmptyCollections(object obj) + { + const BindingFlags bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + foreach (var f in obj.GetType().GetFields(bf)) + { + if (f.GetValue(obj) != null) continue; + var empty = EmptyOf(f.FieldType); + if (empty != null) f.SetValue(obj, empty); + } + } + + private static object EmptyOf(Type t) + { + if (t.IsArray) return Array.CreateInstance(t.GetElementType(), 0); + if (t.IsGenericType) + { + var def = t.GetGenericTypeDefinition(); + if (def == typeof(List<>) || def == typeof(Dictionary<,>) || + def == typeof(HashSet<>) || def == typeof(IList<>) || + def == typeof(IDictionary<,>) || def == typeof(ICollection<>) || + def == typeof(IEnumerable<>)) + { + var concrete = def == typeof(List<>) || def == typeof(IList<>) || + def == typeof(ICollection<>) || def == typeof(IEnumerable<>) + ? typeof(List<>).MakeGenericType(t.GetGenericArguments()) + : def == typeof(HashSet<>) + ? typeof(HashSet<>).MakeGenericType(t.GetGenericArguments()) + : typeof(Dictionary<,>).MakeGenericType(t.GetGenericArguments()); + return Activator.CreateInstance(concrete); + } + } + return null; + } + + private static ClassCharacterMasterData NewChara(int charaId, int classId) + { + var c = (ClassCharacterMasterData)FormatterServices.GetUninitializedObject(typeof(ClassCharacterMasterData)); + SetMember(c, "chara_id", charaId); + SetMember(c, "class_id", classId); + SetMember(c, "skin_id", charaId); + SetMember(c, "is_usable", true); + return c; + } + + // --- reflection helpers (transcribed from the test fixtures) -------------------------------------- + + // Set a member (auto-property backing field or field) by name, tolerating private setters. + private static void SetMember(object obj, string name, object value) + { + var t = obj.GetType(); + const BindingFlags bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + var p = t.GetProperty(name, bf); + if (p != null && p.SetMethod != null) { p.SetValue(obj, value); return; } + var f = t.GetField(name, bf) + ?? t.GetField($"<{name}>k__BackingField", bf); + if (f != null) { f.SetValue(obj, value); return; } + throw new InvalidOperationException($"{t.Name} has no settable member '{name}'"); + } + + // Idempotent backing-field set: only writes when the field is currently 0 (int) or null. + private static void SetFieldIfZeroOrNull(object obj, string name, object value) + { + var f = obj.GetType().GetField(name, + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new InvalidOperationException($"{obj.GetType().Name} has no field '{name}'"); + var cur = f.GetValue(obj); + if (cur is null || (cur is int i && i == 0)) + f.SetValue(obj, value); + } +} diff --git a/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj b/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj index 70d9917..2130347 100644 --- a/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj +++ b/SVSim.EmulatedEntrypoint/SVSim.EmulatedEntrypoint.csproj @@ -26,6 +26,12 @@ + + + + +