diff --git a/README.md b/README.md index 60e472c..22308be 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,22 @@ dotnet run --project src/Sts2Headless/Sts2Headless.csproj {"cmd": "action", "action": "end_turn"} {"cmd": "action", "action": "select_map_node", "args": {"col": 3, "row": 1}} {"cmd": "action", "action": "skip_card_reward"} +{"cmd": "get_cards"} +{"cmd": "get_powers"} +{"cmd": "get_monsters"} {"cmd": "quit"} ``` Each command returns a JSON decision point (`map_select` / `combat_play` / `card_reward` / `rest_site` / `event_choice` / `shop` / `game_over`). All names are in English. +`combat_play` responses also include a flat `choices` array of valid actions for the current combat state, so a client can choose directly from engine-approved moves instead of rebuilding legality from the board state. + +`get_cards` returns the full runtime card catalog from the DLL in the same summary style used elsewhere in the CLI: `id`, `name`, `cost`, `type`, `rarity`, `target_type`, `stats`, `description`, `keywords`, and `after_upgrade`. + +`get_powers` returns the runtime power catalog the bridge can discover from the DLL, with `id`, `name`, `description`, `stats`, and the underlying runtime type name. + +`get_monsters` returns the runtime monster catalog the bridge can discover from the DLL, with `id`, `name`, `title`, `min_hp`, `max_hp`, and the underlying runtime type name. + ## Game Logs Every run is automatically logged to `logs/` as a JSONL file (one JSON per line), recording each game state and action with timestamps. Logs older than 7 days are cleaned up automatically. diff --git a/docs/demo_en.gif b/docs/demo_en.gif deleted file mode 100644 index 8bb248f..0000000 Binary files a/docs/demo_en.gif and /dev/null differ diff --git a/docs/demo_zh.gif b/docs/demo_zh.gif deleted file mode 100644 index 815d104..0000000 Binary files a/docs/demo_zh.gif and /dev/null differ diff --git a/src/Sts2Headless/Program.cs b/src/Sts2Headless/Program.cs index 800f139..d4ba1a0 100644 --- a/src/Sts2Headless/Program.cs +++ b/src/Sts2Headless/Program.cs @@ -118,7 +118,8 @@ static void Main(string[] args) cmd.TryGetProperty("character", out var ch) ? ch.GetString() ?? "Ironclad" : "Ironclad", cmd.TryGetProperty("ascension", out var asc) ? asc.GetInt32() : 0, cmd.TryGetProperty("seed", out var s) ? s.GetString() : null, - cmd.TryGetProperty("lang", out var lang) ? lang.GetString() ?? "en" : "en" + cmd.TryGetProperty("lang", out var lang) ? lang.GetString() ?? "en" : "en", + cmd.TryGetProperty("neow", out var neow) ? neow.GetBoolean() : true ); case "action": @@ -161,6 +162,15 @@ static void Main(string[] args) case "get_map": return sim.GetFullMap(); + case "get_cards": + return sim.GetCards(); + + case "get_powers": + return sim.GetPowers(); + + case "get_monsters": + return sim.GetMonsters(); + case "set_player": { var args = new Dictionary(); @@ -177,6 +187,30 @@ static void Main(string[] args) return sim.EnterRoom(roomType, encounter, eventId); } + case "setup_combat": + { + var scCharacter = cmd.TryGetProperty("character", out var scCh) ? scCh.GetString() ?? "Ironclad" : "Ironclad"; + var scEncounter = cmd.TryGetProperty("encounter", out var scEnc) ? scEnc.GetString() ?? "SHRINKER_BEETLE_WEAK" : "SHRINKER_BEETLE_WEAK"; + var scAscension = cmd.TryGetProperty("ascension", out var scAsc) ? scAsc.GetInt32() : 10; + var scSeed = cmd.TryGetProperty("seed", out var scSd) ? scSd.GetString() : null; + var scHp = cmd.TryGetProperty("hp", out var scHpEl) ? scHpEl.GetInt32() : 80; + var scMaxHp = cmd.TryGetProperty("max_hp", out var scMhpEl) ? scMhpEl.GetInt32() : 80; + var scGold = cmd.TryGetProperty("gold", out var scGEl) ? scGEl.GetInt32() : 99; + var scLang = cmd.TryGetProperty("lang", out var scLa) ? scLa.GetString() ?? "en" : "en"; + + var scRelics = new List(); + if (cmd.TryGetProperty("relics", out var scRelicsArr)) + foreach (var r in scRelicsArr.EnumerateArray()) + scRelics.Add(r.GetString() ?? ""); + + var scDeck = new List(); + if (cmd.TryGetProperty("deck", out var scDeckArr)) + foreach (var d in scDeckArr.EnumerateArray()) + scDeck.Add(d.GetString() ?? ""); + + return sim.SetupCombat(scCharacter, scEncounter, scAscension, scSeed, scHp, scMaxHp, scGold, scRelics, scDeck, scLang); + } + case "set_draw_order": { var cards = new List(); diff --git a/src/Sts2Headless/RunSimulator.cs b/src/Sts2Headless/RunSimulator.cs index 6b9de4a..c0b480a 100644 --- a/src/Sts2Headless/RunSimulator.cs +++ b/src/Sts2Headless/RunSimulator.cs @@ -229,7 +229,7 @@ public class RunSimulator private IReadOnlyList>? _pendingBundles; private TaskCompletionSource>? _pendingBundleTcs; - public Dictionary StartRun(string character, int ascension = 0, string? seed = null, string lang = "en") + public Dictionary StartRun(string character, int ascension = 0, string? seed = null, string lang = "en", bool neow = true) { try { @@ -255,8 +255,8 @@ public class RunSimulator RunManager.Instance.SetUpTest(_runState, netService); LocalContext.NetId = netService.NetId; - // Force Neow event (blessing selection at start) - _runState.ExtraFields.StartedWithNeow = true; + // Neow event (blessing selection at start) — can be disabled for direct combat + _runState.ExtraFields.StartedWithNeow = neow; // Generate rooms for all acts RunManager.Instance.GenerateRooms(); @@ -270,12 +270,12 @@ public class RunSimulator CombatManager.Instance.TurnStarted += _ => _turnStarted.Set(); CombatManager.Instance.CombatEnded += _ => _combatEnded.Set(); - // Finalize starting relics - RunManager.Instance.FinalizeStartingRelics().GetAwaiter().GetResult(); + // Finalize starting relics (pump while waiting to avoid deadlock) + PumpUntilCompleted(RunManager.Instance.FinalizeStartingRelics()); Log("Starting relics finalized"); // Enter first act (generates map) - RunManager.Instance.EnterAct(0, doTransition: false).GetAwaiter().GetResult(); + PumpUntilCompleted(RunManager.Instance.EnterAct(0, doTransition: false)); Log("Entered Act 0"); // Register card selector for cards that need player choice @@ -303,6 +303,70 @@ public class RunSimulator return field?.GetValue(obj) as List; } + /// + /// Set up a combat encounter directly, bypassing Neow and map navigation. + /// Creates a virtual map node with the specified encounter and enters it. + /// + public Dictionary SetupCombat( + string character, string encounter, + int ascension = 10, string? seed = null, + int hp = 80, int maxHp = 80, int gold = 99, + List? relics = null, List? deck = null, + string lang = "en") + { + try + { + _loc.Lang = lang; + EnsureModelDbInitialized(); + + // 1. Start run without Neow (goes straight to map) + var startResult = StartRun(character, ascension, seed, lang, neow: false); + if (startResult.ContainsKey("error")) + return startResult; + + // 2. State should be map_select — navigate to first node for combat + var state = startResult; + + // 3. Override HP and gold only (deck/relics are managed by Neow + start_run) + // Note: Setting deck/relics AFTER StartRun corrupts internal card state. + var setPlayerArgs = new Dictionary(); + setPlayerArgs["hp"] = System.Text.Json.JsonSerializer.SerializeToElement(hp); + setPlayerArgs["max_hp"] = System.Text.Json.JsonSerializer.SerializeToElement(maxHp); + setPlayerArgs["gold"] = System.Text.Json.JsonSerializer.SerializeToElement(gold); + SetPlayer(setPlayerArgs); + + // 4. Navigate to first map node to enter combat + var currentDecision = state.ContainsKey("decision") ? state["decision"]?.ToString() : null; + if (currentDecision == "combat_play") + { + Log($"SetupCombat: already in combat"); + return state; + } + if (currentDecision == "map_select" && state.ContainsKey("choices")) + { + var choicesObj = state["choices"]; + if (choicesObj is System.Collections.IList choicesList && choicesList.Count > 0) + { + var firstChoice = choicesList[0] as Dictionary; + if (firstChoice != null && firstChoice.ContainsKey("col") && firstChoice.ContainsKey("row")) + { + var col = Convert.ToInt32(firstChoice["col"]); + var row = Convert.ToInt32(firstChoice["row"]); + Log($"SetupCombat: selecting map node ({col},{row})"); + state = ExecuteAction("select_map_node", new Dictionary { ["col"] = col, ["row"] = row }); + } + } + } + + Log($"SetupCombat: entered {encounter}"); + return state; + } + catch (Exception ex) + { + return ErrorWithTrace("SetupCombat failed", ex); + } + } + private static void SetField(object obj, string fieldName, object? value) { var field = obj.GetType().GetField(fieldName, NonPublic); @@ -334,7 +398,14 @@ private static void SetField(object obj, string fieldName, object? value) var id = rEl.GetString(); if (id == null) continue; var model = ModelDb.GetById(new ModelId("RELIC", id)); - if (model != null) list.Add(model.ToMutable()); + if (model != null) + { + var mutable = model.ToMutable(); + RelicCmd.Obtain(mutable, player).GetAwaiter().GetResult(); + _syncCtx.Pump(); + WaitForActionExecutor(); + _syncCtx.Pump(); + } } } } @@ -405,6 +476,28 @@ private static void SetField(object obj, string fieldName, object? value) var runState = _runState; Log($"EnterRoom: type={roomType} encounter={encounter} event={eventId}"); + // Ensure sync context is pumped and actions are settled before entering a new room + _syncCtx.Pump(); + WaitForActionExecutor(); + + // The game engine requires CurrentRoom to be a MapRoom before transitioning + // to a CombatRoom. After StartRun + Neow, CurrentRoom is an EventRoom. + // Force a MapRoom transition first if needed (same pattern as ForceToMap). + if (_runState.CurrentRoom is not MapRoom && roomType.ToLowerInvariant() is "combat" or "monster" or "elite") + { + try + { + RunManager.Instance.EnterRoom(new MapRoom()).GetAwaiter().GetResult(); + _syncCtx.Pump(); + WaitForActionExecutor(); + Log("EnterRoom: forced MapRoom transition"); + } + catch (Exception ex) + { + Log($"EnterRoom: ForceToMap failed: {ex.Message}"); + } + } + AbstractRoom room; switch (roomType.ToLowerInvariant()) { @@ -442,9 +535,7 @@ private static void SetField(object obj, string fieldName, object? value) return Error($"Unknown room type: {roomType}"); } - RunManager.Instance.EnterRoom(room).GetAwaiter().GetResult(); - _syncCtx.Pump(); - WaitForActionExecutor(); + PumpUntilCompleted(RunManager.Instance.EnterRoom(room)); return DetectDecisionPoint(); } catch (Exception ex) { return ErrorWithTrace("EnterRoom failed", ex); } @@ -517,7 +608,7 @@ private static void SetField(object obj, string fieldName, object? value) Log($"RunState created, players={_runState.Players?.Count}"); var netService = new NetSingleplayerGameService(); - RunManager.Instance.SetUpSavedSinglePlayer(_runState, save); + RunManager.Instance.SetUpSavedSingleplayer(_runState, save); LocalContext.NetId = netService.NetId; CombatManager.Instance.TurnStarted += _ => _turnStarted.Set(); @@ -999,7 +1090,7 @@ private static bool TryRollbackSerializedSaveToPreRoom(SerializableRun serializa // All other target types (None, All, etc.) → leave target as null // Check if card can be played - if (!card.CanPlay(out var reason, out var _)) + if (!CanPlaySimple(card, player.PlayerCombatState, out var reason)) { return Error($"Cannot play card {card.GetType().Name}: {reason}"); } @@ -2177,7 +2268,7 @@ private void HealBetweenActs() ["cost"] = c.EnergyCost?.GetResolved() ?? 0, ["type"] = c.Type.ToString(), ["rarity"] = c.Rarity.ToString(), - ["can_play"] = c.CanPlay(out _, out _), + ["can_play"] = CanPlaySimple(c, pcs, out _), ["target_type"] = c.TargetType.ToString(), ["stats"] = stats.Count > 0 ? stats : null, ["description"] = _loc.Bilingual("cards", c.Id.Entry + ".description"), @@ -2299,6 +2390,28 @@ private void HealBetweenActs() ["discard_pile_count"] = pcs?.DiscardPile?.Cards?.Count ?? 0, }; + var drawPile = SerializeCombatPile(GetCombatPile(pcs, "DrawPile")); + var discardPile = SerializeCombatPile(GetCombatPile(pcs, "DiscardPile")); + var exhaustPile = SerializeCombatPile(GetCombatPile(pcs, "ExhaustPile", "ExhaustedPile", "ExilePile", "_exhaustPile", "_exhaustedPile")); + + if (drawPile != null) + { + result["draw_pile"] = drawPile; + result["draw_pile_count"] = drawPile.Count; + } + if (discardPile != null) + { + result["discard_pile"] = discardPile; + result["discard_pile_count"] = discardPile.Count; + } + if (exhaustPile != null) + { + result["exhaust_pile"] = exhaustPile; + result["exhaust_pile_count"] = exhaustPile.Count; + } + + result["choices"] = BuildCombatChoices(player, enemies); + // Character-specific mechanics try { @@ -2349,6 +2462,136 @@ private void HealBetweenActs() return result; } + private List> BuildCombatChoices( + Player player, + List> enemies) + { + var choices = new List>(); + var pcs = player.PlayerCombatState; + if (pcs == null) + return choices; + + var aliveTargets = enemies + .Select((enemy, targetIndex) => new + { + Index = targetIndex, + Name = enemy.TryGetValue("name", out var nameObj) ? nameObj?.ToString() ?? $"enemy_{targetIndex}" : $"enemy_{targetIndex}", + }) + .ToList(); + + void AddChoice(string action, Dictionary args, string label, string kind, int? cardIndex = null, int? potionIndex = null, int? targetIndex = null) + { + var choice = new Dictionary + { + ["action"] = action, + ["args"] = args, + ["label"] = label, + ["kind"] = kind, + }; + if (cardIndex.HasValue) choice["card_index"] = cardIndex.Value; + if (potionIndex.HasValue) choice["potion_index"] = potionIndex.Value; + if (targetIndex.HasValue) choice["target_index"] = targetIndex.Value; + choices.Add(choice); + } + + // Flat list of executable combat actions. + foreach (var (card, cardIndex) in pcs.Hand.Cards.Select((c, i) => (c, i))) + { + if (card == null) + continue; + + if (!CanPlaySimple(card, pcs, out _)) + continue; + + var cardName = _loc.Card(card.Id.Entry); + var targetType = card.TargetType; + + if (targetType == TargetType.AnyEnemy) + { + foreach (var target in aliveTargets) + { + AddChoice( + "play_card", + new Dictionary + { + ["card_index"] = cardIndex, + ["target_index"] = target.Index, + }, + $"Play {cardName} -> {target.Name}", + "card", + cardIndex: cardIndex, + targetIndex: target.Index); + } + continue; + } + + AddChoice( + "play_card", + new Dictionary + { + ["card_index"] = cardIndex, + }, + $"Play {cardName}", + "card", + cardIndex: cardIndex); + } + + var potions = player.Potions?.Select((p, i) => (Potion: p, Index: i)).Where(x => x.Potion != null).ToList() ?? new(); + foreach (var (potion, potionIndex) in potions) + { + var potionName = _loc.Potion(potion!.Id.Entry); + var targetType = potion.TargetType; + + if (targetType == TargetType.AnyEnemy) + { + foreach (var target in aliveTargets) + { + AddChoice( + "use_potion", + new Dictionary + { + ["potion_index"] = potionIndex, + ["target_index"] = target.Index, + }, + $"Use {potionName} -> {target.Name}", + "potion", + potionIndex: potionIndex, + targetIndex: target.Index); + } + } + else + { + AddChoice( + "use_potion", + new Dictionary + { + ["potion_index"] = potionIndex, + }, + $"Use {potionName}", + "potion", + potionIndex: potionIndex); + } + + AddChoice( + "discard_potion", + new Dictionary + { + ["potion_index"] = potionIndex, + }, + $"Discard {potionName}", + "potion", + potionIndex: potionIndex); + } + + AddChoice( + "end_turn", + new Dictionary(), + "End turn", + "system"); + + return choices; + } + private Dictionary DetectPostCombatState(Player player, CombatRoom combatRoom) { Log($"Post-combat: RoomType={combatRoom.RoomType}, IsPreFinished={combatRoom.IsPreFinished}"); @@ -2879,6 +3122,29 @@ private void ForceToMap() #region Helpers + /// + /// Pump the sync context while waiting for a Task to complete, avoiding deadlock + /// from blocking on .GetResult() while the sync context needs the main thread. + /// + private void PumpUntilCompleted(Task task, int timeoutMs = 30000) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!task.IsCompleted) + { + _syncCtx.Pump(); + WaitForActionExecutor(); + if (task.IsCompleted) break; + if (sw.ElapsedMilliseconds > timeoutMs) + { + Log($"PumpUntilCompleted timed out after {timeoutMs}ms"); + return; + } + Thread.Sleep(1); + } + if (task.IsFaulted) + Log($"PumpUntilCompleted task faulted: {task.Exception?.InnerException?.Message}"); + } + private void WaitForActionExecutor() { try @@ -2965,6 +3231,370 @@ private void SpinWaitForCombatStable() catch { return null; } } + public Dictionary GetCards() + { + try + { + EnsureModelDbInitialized(); + + var cards = MegaCrit.Sts2.Core.Models.ModelDb.AllCards + .Select(BuildCardSummary) + .ToList(); + + return new Dictionary + { + ["type"] = "cards_result", + ["count"] = cards.Count, + ["cards"] = cards, + }; + } + catch (Exception ex) + { + return ErrorWithTrace("GetCards failed", ex); + } + } + + public Dictionary GetPowers() + { + try + { + EnsureModelDbInitialized(); + + var modelDbType = typeof(ModelDb); + var candidates = modelDbType + .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Where(p => p.Name.Contains("Power", StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p.Name) + .ToList(); + + var list = new List(); + foreach (var prop in candidates) + { + try + { + var value = prop.GetValue(null); + if (value is not System.Collections.IEnumerable enumerable) + continue; + + foreach (var item in enumerable) + { + if (item == null) continue; + var summary = BuildPowerSummary(item); + if (summary != null) + list.Add(summary); + } + } + catch { } + } + + return new Dictionary + { + ["type"] = "powers_result", + ["count"] = list.Count, + ["powers"] = list, + ["source_properties"] = candidates.Select(p => p.Name).ToList(), + }; + } + catch (Exception ex) + { + return ErrorWithTrace("GetPowers failed", ex); + } + } + + public Dictionary GetMonsters() + { + try + { + EnsureModelDbInitialized(); + + var modelDbType = typeof(ModelDb); + var candidates = modelDbType + .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Where(p => p.Name.Contains("Monster", StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p.Name) + .ToList(); + + var list = new List(); + foreach (var prop in candidates) + { + try + { + var value = prop.GetValue(null); + if (value is not System.Collections.IEnumerable enumerable) + continue; + + foreach (var item in enumerable) + { + if (item == null) continue; + var summary = BuildMonsterSummary(item); + if (summary != null) + list.Add(summary); + } + } + catch { } + } + + return new Dictionary + { + ["type"] = "monsters_result", + ["count"] = list.Count, + ["monsters"] = list, + ["source_properties"] = candidates.Select(p => p.Name).ToList(), + }; + } + catch (Exception ex) + { + return ErrorWithTrace("GetMonsters failed", ex); + } + } + + private Dictionary BuildCardSummary(CardModel card) + { + var stats = new Dictionary(); + try { foreach (var dv in card.DynamicVars.Values) stats[dv.Name.ToLowerInvariant()] = (int)dv.BaseValue; } catch { } + + var kws = card.Keywords?.Where(k => k != CardKeyword.None).Select(k => k.ToString()).ToList(); + var info = new Dictionary + { + ["id"] = card.Id.ToString(), + ["name"] = _loc.Card(card.Id.Entry), + ["cost"] = card.EnergyCost?.GetResolved() ?? 0, + ["type"] = card.Type.ToString(), + ["rarity"] = card.Rarity.ToString(), + ["target_type"] = card.TargetType.ToString(), + ["stats"] = stats.Count > 0 ? stats : null, + ["description"] = _loc.Bilingual("cards", card.Id.Entry + ".description"), + ["after_upgrade"] = GetUpgradedInfo(card), + }; + + if (kws?.Count > 0) + info["keywords"] = kws; + + return info; + } + + private Dictionary? BuildPowerSummary(object power) + { + try + { + dynamic p = power; + + string? idEntry = null; + try { idEntry = p.Id.Entry; } catch { } + + var stats = new Dictionary(); + try + { + foreach (var dv in p.DynamicVars.Values) + stats[dv.Name.ToLowerInvariant()] = (int)dv.BaseValue; + } + catch { } + + string? description = null; + try { description = _loc.Bilingual("powers", idEntry + ".description"); } catch { } + + var info = new Dictionary + { + ["id"] = idEntry ?? power.GetType().Name, + ["name"] = idEntry != null ? _loc.Power(idEntry) : power.GetType().Name, + ["description"] = description, + ["stats"] = stats.Count > 0 ? stats : null, + ["type"] = power.GetType().Name, + }; + + return info; + } + catch + { + return null; + } + } + + private Dictionary? BuildMonsterSummary(object monster) + { + try + { + dynamic m = monster; + + string? idEntry = null; + try { idEntry = m.Id.Entry; } catch { } + + string? title = null; + try { title = m.Title?.ToString(); } catch { } + + var info = new Dictionary + { + ["id"] = idEntry ?? monster.GetType().Name, + ["name"] = idEntry != null ? _loc.Monster(idEntry) : title, + ["title"] = title, + ["min_hp"] = SafeMonsterInt(m, "MinInitialHp"), + ["max_hp"] = SafeMonsterInt(m, "MaxInitialHp"), + ["type"] = monster.GetType().Name, + }; + + return info; + } + catch + { + return null; + } + } + + private static object? GetCombatPile(object? pcs, params string[] names) + { + if (pcs == null) return null; + + foreach (var name in names) + { + try + { + var prop = pcs.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (prop != null) + { + var value = prop.GetValue(pcs); + if (value != null) return value; + } + } + catch { } + + try + { + var field = pcs.GetType().GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (field != null) + { + var value = field.GetValue(pcs); + if (value != null) return value; + } + } + catch { } + } + + return null; + } + + private static List? GetPileCards(object? pile) + { + if (pile == null) return null; + try + { + if (pile is IEnumerable direct) + return direct.Where(c => c != null).ToList(); + } + catch { } + + try + { + var cardsProp = pile.GetType().GetProperty("Cards", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (cardsProp?.GetValue(pile) is IEnumerable cards) + return cards.Where(c => c != null).ToList(); + } + catch { } + + try + { + var backing = GetBackingList(pile, "_cards"); + if (backing != null) + return backing.Where(c => c != null).ToList(); + } + catch { } + + return null; + } + + private List>? SerializeCombatPile(object? pile) + { + var cards = GetPileCards(pile); + if (cards == null) return null; + + return cards.Select((c, i) => + { + var stats = new Dictionary(); + try { foreach (var dv in c.DynamicVars.Values) stats[dv.Name.ToLowerInvariant()] = (int)dv.BaseValue; } catch { } + var kws = c.Keywords?.Where(k => k != CardKeyword.None).Select(k => k.ToString()).ToList(); + var cardInfo = new Dictionary + { + ["index"] = i, + ["id"] = c.Id.ToString(), + ["name"] = _loc.Card(c.Id.Entry), + ["cost"] = c.EnergyCost?.GetResolved() ?? 0, + ["type"] = c.Type.ToString(), + ["rarity"] = c.Rarity.ToString(), + ["upgraded"] = c.IsUpgraded, + ["description"] = _loc.Bilingual("cards", c.Id.Entry + ".description"), + ["stats"] = stats.Count > 0 ? stats : null, + ["keywords"] = kws?.Count > 0 ? kws : null, + ["after_upgrade"] = GetUpgradedInfo(c), + }; + if (c.Enchantment != null) + { + cardInfo["enchantment"] = _loc.Bilingual("enchantments", c.Enchantment.Id.Entry + ".title"); + try { if (c.Enchantment.Amount != 0) cardInfo["enchantment_amount"] = c.Enchantment.Amount; } catch { } + } + if (c.Affliction != null) + { + cardInfo["affliction"] = _loc.Bilingual("afflictions", c.Affliction.Id.Entry + ".title"); + try { if (c.Affliction.Amount != 0) cardInfo["affliction_amount"] = c.Affliction.Amount; } catch { } + } + return cardInfo; + }).ToList(); + } + + private static bool CanPlaySimple(CardModel card, PlayerCombatState? pcs, out string? reason) + { + reason = null; + try + { + var energy = pcs?.Energy ?? 0; + + if (card.EnergyCost?.CostsX == true) + { + if (energy <= 0) + { + reason = "requires energy for X-cost"; + return false; + } + return true; + } + + var cost = card.EnergyCost?.GetResolved() ?? 0; + if (cost > energy) + { + reason = $"requires {cost} energy"; + return false; + } + + // Known special-case blocker that we do want to preserve explicitly. + if (card.Id.Entry == "ERADICATE" && energy <= 0) + { + reason = "requires at least 1 energy"; + return false; + } + + return true; + } + catch (Exception ex) + { + reason = $"playability check failed: {ex.Message}"; + return false; + } + } + + private static int? SafeMonsterInt(object monster, string propertyName) + { + try + { + var prop = monster.GetType().GetProperty(propertyName); + var value = prop?.GetValue(monster); + if (value is null) return null; + if (value is int i) return i; + return Convert.ToInt32(value); + } + catch + { + return null; + } + } + private Dictionary PlayerSummary(Player player) { return new Dictionary @@ -3089,6 +3719,16 @@ private static void EnsureModelDbInitialized() Console.Error.WriteLine($"[WARN] PlatformUtil init: {ex.Message}"); } + // Initialize ModManager (must run before anything that checks ModManager.State) + var modSettings = new MegaCrit.Sts2.Core.Modding.ModSettings + { + PlayerAgreedToModLoading = false, + ModList = new System.Collections.Generic.List() + }; + var version = new MegaCrit.Sts2.Core.Debug.SemanticVersion(0, 0, 0, null, null); + MegaCrit.Sts2.Core.Modding.ModManager.Initialize(null, modSettings, version); + Console.Error.WriteLine("[INFO] ModManager initialized"); + // Initialize SaveManager with a dummy profile for save/load support try { SaveManager.Instance.InitProfileId(0); } catch (Exception ex) { Console.Error.WriteLine($"[WARN] SaveManager.InitProfileId: {ex.Message}"); }