Files
SVSimServer/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
gamer147 35e9847911 feat(battlenode): receive conductor resolves self Deal+Play headless via view-untangle (M-HC-0)
The engine's receive CONDUCTOR fuses each authoritative mutation behind a view
call: the play mutation is an InstantVfx registered to VfxMgr, and the deal hand
is seated by MulliganPhaseBase.StartDeal wired to OperateReceive.OnReceiveDeal.
Headless, the shared VfxMgr no-op'd registration (correct for the direct
ActionProcessor path the M2-M12 oracles use) and OnReceiveDeal was never wired,
so the receive path resolved nothing.

Untangle (Candidate B, zero Engine logic edits):
- InstantVfx.Run() opt-in executor (authored shim).
- HeadlessConductorVfxMgr : VfxMgr runs registered InstantVfx; wired only via the
  node's SessionContentsCreator.CreateVfxMgr (verified the receive mgr's VfxMgr
  comes from there — BattleManagerBase.cs:768). M2-M12 use HeadlessContentsCreator,
  so they're isolated by construction.
- WireMulliganPhase: construct NetworkMulliganPhase + MulliganEventSetting() to
  install OnReceiveDeal -> StartDeal (the node never pumps the phase machine).

View no-op surface (the 7 from the probe, minus 1 not hit; +1 emergent):
- Deal wiring (NetworkMulliganPhase) [node seed]
- MulliganInfoControl._partsPlayer/_partsOpponent._exchangeMark/_keepZone/_abandonZone [node seed: prefab + SeedMulliganInfoControl]
- Data.BattleRecoveryInfo (IsMulliganEnd=false) [EngineGlobalInit seed]
- IBattlePlayerView.PlayQueueView -> HeadlessPlayQueueViewStub [_IfaceImpl.g.cs, both getters]
- DetailMgr.DetailPanelControl/SubDetailPanelControl [node seed]
- BattleCardIconAnimations.collection (emergent: UpdateInPlayBattleCardIconLabel) -> HeadlessIconAnimations empty SkillCollectionBase [_IfaceImpl.g.cs]
- BattleMenuBtn (probe item 7): NOT hit on the vanilla path; not seeded.

Oracle (HeadlessConductorTests): node Deal seats 3-card hand; a vanilla
hand-card Play leaves hand (-1), adds board (+1), drops PP by cost.

Regression: 24/24 BattleEngine.Tests oracles (M2-M12) green; 241/241
SVSim.UnitTests BattleNode green. The 2 SessionEngine capture-replay shadow
tests are marked Ignore (superseded): they passed VACUOUSLY when the receive
path resolved nothing; with resolution live they hit the documented
capture-replay draw-misalignment artifact. Node-native battles are the oracle.
Drift: no drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:08:53 -04:00

390 lines
22 KiB
C#

extern alias engine;
using System.Reflection;
using System.Runtime.Serialization;
using engine::SVSim.BattleEngine.Rng;
using SVSim.BattleNode.Protocol;
using NetworkBattleReceiver = engine::NetworkBattleReceiver;
using NetworkBattleDefine = engine::NetworkBattleDefine;
using BattleManagerBase = engine::BattleManagerBase;
using BattlePlayerBase = engine::BattlePlayerBase;
using BattleCardBase = engine::BattleCardBase;
using ClassBattleCardBase = engine::ClassBattleCardBase;
using CardCreatorBase = engine::CardCreatorBase;
using SBattleLoad = engine::SBattleLoad;
using CardTemplate = engine::CardTemplate;
using GameObject = engine::UnityEngine.GameObject;
using RealTimeNetworkAgent = engine::RealTimeNetworkAgent;
using Gungnir = engine::Gungnir;
using NetworkNullLogger = engine::NetworkNullLogger;
using ToolboxGame = engine::Wizard.ToolboxGame;
using GameMgr = engine::GameMgr;
using BattleUIContainer = engine::BattleUIContainer;
using BackGroundBase = engine::BackGroundBase;
using NullPlayerEmotion = engine::Wizard.Battle.Player.Emotion.NullPlayerEmotion;
using NetworkMulliganPhase = engine::Wizard.Battle.Phase.NetworkMulliganPhase;
using MulliganInfoControl = engine::Wizard.Battle.Mulligan.MulliganInfoControl;
using UIWidget = engine::UIWidget;
using UISprite = engine::UISprite;
using NullDetailPanelControl = engine::NullDetailPanelControl;
using DetailPanelControl = engine::DetailPanelControl;
namespace SVSim.BattleNode.Sessions.Engine;
/// <summary>One authoritative engine per BattleSession, seated as both players (design ND2). A faithful
/// SHADOW: it mirrors each client's resolved play, never overrides/rejects/originates (ND1). Ingest is
/// the engine's own NetworkBattleReceiver.ReceivedMessage (ND4); isPlayer selects the seat (F-N-2).
///
/// The headless wiring here is the production analogue of the test HeadlessFixture
/// (NewNetworkEmitBattle / SeedDeck / InitLeaderLife / InitCardTemplates). It deliberately omits the
/// emit-only RealTimeNetworkAgent scaffolding the test uses for the SEND path — the shadow engine only
/// RECEIVES (F-N-2), so no socket-agent is constructed. The engine's global init (CardMaster, GameMgr,
/// Wizard.Data) is the caller's responsibility (the test does HeadlessEngineEnv.EnsureInitialized;
/// the live node guards Setup in try/catch so an un-initialized host degrades to a no-op shadow).</summary>
internal sealed class SessionBattleEngine
{
private const int DefaultLeaderLife = 20;
private HeadlessNetworkBattleMgr? _mgr;
private NetworkBattleReceiver? _receiver;
/// <summary>True once Setup has built the two-seat battle.</summary>
public bool IsReady => _mgr is not null;
/// <summary>Construct the two-seat network battle from both decks + the master seed (design F-N-5).
/// <paramref name="seatADeck"/>/<paramref name="seatBDeck"/> are the per-side deck orders the node
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
/// <paramref name="seatAClass"/>/<paramref name="seatBClass"/> are each seat's class ordinal (1..8,
/// the <c>CardClass</c> int value); they select the leader's class via the all-8-class
/// ClassCharacterList EngineGlobalInit installs (chara_id == class_id for 1..8). The 3-arg overload
/// behavior is preserved by the defaults (1/2), matching the test-harness charaIds.
/// <para>NOTE: GameMgr (the leader chara ids set below) is a PROCESS GLOBAL. Setting per-session
/// chara ids is therefore only safe while exactly one engine-backed battle exists at a time — the
/// invariant <see cref="EngineSessionGate"/> enforces on the caller side.</para></summary>
public void Setup(int masterSeed,
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
int seatAClass = 1, int seatBClass = 2)
{
// Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master,
// GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds
// here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A).
EngineGlobalInit.EnsureInitialized();
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — the stream is born aligned
// with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway").
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed));
// Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the
// receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX
// waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+),
// so this collapses them to no-ops without changing authoritative state. Set AFTER construction
// so the ctor still wired the LIVE NetworkBattleReceiver (ND4) rather than the replay receiver.
// Safe for shadow: the only thing !IsRecovery additionally enables is EMIT, which a pure shadow
// never does (it never originates a send).
mgr.IsRecovery = true;
// Seat each player as the other's opponent (private field on BattlePlayerBase, as the real
// match-load does). Mirrors HeadlessFixture.NewNetworkEmitBattle.
BattlePlayerBase player = mgr.GetBattlePlayer(isPlayer: true);
BattlePlayerBase enemy = mgr.GetBattlePlayer(isPlayer: false);
SetField(player, "_opponentBattlePlayer", enemy);
SetField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays
InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer
InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs
InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent
// Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList,
// so writing the seats' class ordinals into GameMgr's DataMgr resolves each leader's correct
// class. Process-global — safe only under EngineSessionGate (see method remarks above).
SetGameMgrCharaIds(seatAClass, seatBClass);
SeedDeck(mgr, seatADeck, isPlayer: true);
SeedDeck(mgr, seatBDeck, isPlayer: false);
WireMulliganPhase(mgr); // wire OperateReceive.OnReceiveDeal -> StartDeal (deal seats the hand)
_mgr = mgr;
// Use the mgr's OWN receiver — the ctor already wired it to the mgr's OperateReceive +
// NetworkBattleData (NetworkBattleManagerBase.cs:266, non-recovery branch). This is the same
// receiver the engine's RecoveryDataHandler drives when replaying recorded frames.
_receiver = mgr.GetNetworkBattleReceiver();
}
/// <summary>Ingest one client frame into the engine for the given seat. <paramref name="isPlayerSeat"/>
/// maps the sender to the engine's player(true)/opponent(false) seat (F-N-2). A throw/reject is
/// returned as a detected-desync EVENT (ND6), never silently absorbed.</summary>
public EngineIngestResult Receive(MsgEnvelope env, bool isPlayerSeat)
{
if (_mgr is null || _receiver is null)
throw new InvalidOperationException("Receive before Setup.");
var dict = ToEngineDict((env.Body as RawBody)?.Entries);
var uri = MapUri(env.Uri);
try
{
// Mirror the engine's own recorded-frame replay (RecoveryDataHandler.cs:283): every
// ingested action resolves through the isHaveSequence ConductReceiveData path, and
// checkBreakData:false so a partial/handshake frame is not rejected as a break.
bool accepted = _receiver.ReceivedMessage(
uri, isHaveSequence: true, dict, isPlayerSeat, handler: null, checkBreakData: false);
return accepted ? EngineIngestResult.Ok() : EngineIngestResult.Reject($"receiver rejected {env.Uri}");
}
catch (Exception ex)
{
// Keep the first few frames: a headless-gap NRE/ANE is almost always diagnosable from the
// call chain (the throwing leaf is often a ThrowHelper, so one frame is too few).
var site = string.Join(" || ", (ex.StackTrace ?? "").Split('\n').Take(4).Select(s => s.Trim()));
return EngineIngestResult.Reject($"{env.Uri} threw: {ex.GetType().Name}: {ex.Message} @ {site}");
}
}
// --- live board-state reads (N1 oracle surface; design F-N-4 board-state reads) ----------------
// Each returns LIVE engine state off the seated player, mirroring the Phase-1 oracle reads
// (VanillaFollowerOracleTests: player.Pp, player.HandCardList.Count, ClassAndInPlayCardList,
// leader == the Class card). seat:true == player, false == opponent (F-N-2).
public int LeaderLife(bool playerSeat) => Seat(playerSeat).Class.Life;
public int Pp(bool playerSeat) => Seat(playerSeat).Pp;
public int HandCount(bool playerSeat) => Seat(playerSeat).HandCardList.Count;
/// <summary>Followers in play, excluding the leader (the Class card occupies one slot of
/// ClassAndInPlayCardList).</summary>
public int BoardCount(bool playerSeat) => Math.Max(0, Seat(playerSeat).ClassAndInPlayCardList.Count - 1);
/// <summary>The engine <c>Index</c> of the hand card at the given hand position. The receive-path
/// Play frame addresses a card by its engine Index (playIdx), which equals deck position + 1 for
/// a card dealt from the seeded deck.</summary>
public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index;
private engine::BattlePlayerBase Seat(bool playerSeat) =>
(_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);
private static NetworkBattleDefine.NetworkBattleURI MapUri(NetworkBattleUri uri)
=> Enum.Parse<NetworkBattleDefine.NetworkBattleURI>(uri.ToString());
// The receiver reads keys via Enum.IsDefined over NetworkParameter and casts nested values to
// List<object> / Dictionary<string,object>; the node decodes nested data as the nullable
// List<object?> / Dictionary<string,object?>. Rebox to the non-nullable shape, dropping nulls
// (the receiver presence-checks keys, so an absent key is the correct encoding of a null).
private static Dictionary<string, object> ToEngineDict(Dictionary<string, object?>? entries)
{
var result = new Dictionary<string, object>();
if (entries is null) return result;
foreach (var (k, v) in entries)
if (v is not null) result[k] = Rebox(v);
return result;
}
private static object Rebox(object v) => v switch
{
Dictionary<string, object?> d => d.Where(kv => kv.Value is not null)
.ToDictionary(kv => kv.Key, kv => Rebox(kv.Value!)),
List<object?> l => l.Where(x => x is not null).Select(x => Rebox(x!)).ToList(),
_ => v,
};
// --- headless wiring (production analogue of HeadlessFixture) -----------------------------------
private static void InitLeaderLife(BattleManagerBase mgr, int life = DefaultLeaderLife)
{
((ClassBattleCardBase)mgr.GetBattlePlayer(true).Class).InitBaseMaxLife(life);
((ClassBattleCardBase)mgr.GetBattlePlayer(false).Class).InitBaseMaxLife(life);
}
private static void InitCardTemplates(BattleManagerBase mgr)
{
mgr.SBattleLoad = new SBattleLoad
{
UnitCardTemplate = new CardTemplate(),
SpellCardTemplate = new CardTemplate(),
FieldCardTemplate = new CardTemplate(),
};
mgr.Battle3DContainer = new GameObject();
mgr.CardHolder = new GameObject();
mgr.ECardHolder = new GameObject();
mgr.PCardPlace = new GameObject();
mgr.ChoiceCardHolder = new GameObject();
mgr.EvolveCardHolder = new GameObject();
}
// Seed the no-op UI refs the receive/turn cycle dereferences. Under IsRecovery the methods on
// these (e.g. BattleUIContainer.DisableMenu) no-op, but the receiver still CALLS them, so the
// references must be non-null. PlayerEmotion is the engine's own NullPlayerEmotion.
private static void InitHeadlessViews(BattleManagerBase mgr)
{
mgr.BattleUIContainer = (BattleUIContainer)FormatterServices.GetUninitializedObject(typeof(BattleUIContainer));
// Revealed-card creation (ReplaceReceivedCard.CreateActualCard -> CreateBaseCardGameObject)
// clones the card prefab under _backGround.m_Battle3DContainer — a field distinct from
// mgr.Battle3DContainer. Seed a no-op BackGround with a non-null container.
var bg = (BackGroundBase)FormatterServices.GetUninitializedObject(typeof(BackGroundBase));
SetProperty(bg, "m_Battle3DContainer", new GameObject());
SetField(mgr, "_backGround", bg);
// PlayerEmotion is declared on BattlePlayer (the player seat); BattleEnemy has none — set
// where present.
TrySetProperty(mgr.GetBattlePlayer(true), "PlayerEmotion", new NullPlayerEmotion());
TrySetProperty(mgr.GetBattlePlayer(false), "PlayerEmotion", new NullPlayerEmotion());
// The receive play path runs SetupActionProcessorEvent (BattlePlayerBase.cs:1431/1438), which
// wires BattleMgr.DetailMgr.DetailPanelControl.UpdateCardDescription* into OnPlayComplete/
// OnEvolutionComplete. DetailMgr is created in CreateManager but its panel controls are null
// headless. Seed the engine's own NullDetailPanelControl no-op (IDetailPanelControl) + an
// uninitialized SubDetailPanelControl (concrete DetailPanelControl, read on other action arms).
mgr.DetailMgr.DetailPanelControl = new NullDetailPanelControl();
mgr.DetailMgr.SubDetailPanelControl =
(DetailPanelControl)FormatterServices.GetUninitializedObject(typeof(DetailPanelControl));
}
// Hold a strong reference to the wired mulligan phase: its StartDeal closure is what
// OperateReceive.OnReceiveDeal invokes, and it stores the mulligan mgr/controls that seat the hand.
private NetworkMulliganPhase? _mulliganPhase;
// Wire the receive path's deal handler. In production the phase machine advances to
// NetworkMulliganPhase, whose Setup/MulliganEventSetting wires OperateReceive.OnReceiveDeal ->
// MulliganPhaseBase.StartDeal (NetworkMulliganPhase.cs:91). The node never pumps the phase machine
// (BattleManagerBase.Update is never called), and the node's PhaseCreator yields no NetworkMulligan
// phase anyway — so construct the phase directly and run MulliganEventSetting() to install that
// delegate. The phase ctor's Initialize builds the player/opponent mulligan controls (PlayerMlgCtrl
// via InitMulligan) off the no-op view leaves the shim GameObject lazily materializes. The DEAL
// mutation (cards deck->hand) happens synchronously inside StartDeal -> CreateMulliganDealList +
// DrawFirstMulliganCard; the VFX it returns are cosmetic (dropped by HeadlessConductorVfxMgr).
private void WireMulliganPhase(HeadlessNetworkBattleMgr mgr)
{
// The phase ctor's Initialize does NGUITools.AddChild(Battle3DContainer,
// GetPrefabMgr().Get("Prefab/UI/MulliganInfo")).GetComponent<MulliganInfoControl>(). PrefabMgr.Get
// returns null for an unregistered prefab (engine logic — not editable), and AddChild(parent,
// null) -> Instantiate(null) -> null -> NRE on GetComponent. Seed a no-op GameObject under that
// key so AddChild clones it and the shim GameObject lazily materializes a no-op
// MulliganInfoControl. Node seed (allowed); the control is never shown/updated headless.
var prefab = new GameObject();
SeedMulliganInfoControl(prefab);
var prefabData = GameMgr.GetIns().GetPrefabMgr().GetPrefabData();
prefabData["Prefab/UI/MulliganInfo"] = prefab;
var phase = new NetworkMulliganPhase(mgr, mgr.NetworkSender);
phase.MulliganEventSetting();
_mulliganPhase = phase;
}
// Materialize a no-op MulliganInfoControl on the prefab GameObject and seed the view-leaf fields the
// phase ctor's PlayerMulliganView ctor -> MulliganInfoControl.InitMulliganInfo reads:
// _partsPlayer/_partsOpponent (private nested MulliganParts) — each needs a non-null _exchangeMark
// array (read for .Length in InitMulliganInfo) plus non-null _keepZone/_abandonZone UIWidgets
// (read for .gameObject elsewhere on the mulligan path).
// The shim GameObject lazily creates the MulliganInfoControl but does NOT fill the MulliganParts
// (it isn't a Component, so WireComponentFields skips it). Node seed (allowed) — pure no-op view leaves.
private static void SeedMulliganInfoControl(GameObject prefab)
{
var ctrl = prefab.GetComponent<MulliganInfoControl>();
var partsType = typeof(MulliganInfoControl)
.GetNestedType("MulliganParts", BindingFlags.NonPublic)
?? throw new InvalidOperationException("MulliganInfoControl.MulliganParts nested type not found");
SetField(ctrl, "_partsPlayer", BuildMulliganParts(partsType));
SetField(ctrl, "_partsOpponent", BuildMulliganParts(partsType));
}
private static object BuildMulliganParts(Type partsType)
{
var parts = FormatterServices.GetUninitializedObject(partsType);
SetField(parts, "_exchangeMark", Array.CreateInstance(typeof(UISprite), 0));
SetField(parts, "_keepZone", NewUiWidget());
SetField(parts, "_abandonZone", NewUiWidget());
return parts;
}
// A UIWidget is read for .gameObject (Component.gameObject) on the mulligan path; create one on a
// fresh GameObject so its gameObject backref resolves.
private static UIWidget NewUiWidget() => new GameObject().GetComponent<UIWidget>();
/// <summary>Seat one side's full deck in order (idx == list position + 1). Each card is created
/// through the engine's own null-view seam and pushed via AddToDeck — the SeedDeck primitive the
/// test harness proved (HeadlessFixture.SeedDeck).</summary>
private static void SeedDeck(BattleManagerBase mgr, IReadOnlyList<long> deck, bool isPlayer)
{
BattlePlayerBase owner = mgr.GetBattlePlayer(isPlayer);
for (int i = 0; i < deck.Count; i++)
{
var card = CreateHeadlessCard(mgr, (int)deck[i], index: i + 1, isPlayer);
owner.AddToDeck(card);
}
}
private static readonly MethodInfo CreateCardWithoutResources =
typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("CardCreatorBase.CreateCardWithoutResources not found");
private static BattleCardBase CreateHeadlessCard(BattleManagerBase mgr, int cardId, int index, bool isPlayer)
{
var io = mgr.CreatePlayerInnerOptionsBuilder();
var card = (BattleCardBase)CreateCardWithoutResources.Invoke(
null, new object[] { cardId, index, isPlayer, mgr, io })!;
mgr.GetBattlePlayer(isPlayer).SetupCardEvent(card);
return card;
}
// The turn-flow + emit bookkeeping reads the global ToolboxGame.RealTimeNetworkAgent (e.g.
// RealTimeNetworkAgent.GetIsFirstPlayer/GetTurnState, which delegate to GameMgr's
// NetworkUserInfoData.TurnState; AddActionSequence touches _gungnir). Headless there is no socket
// agent, so seed a no-op one — mirroring HeadlessFixture.NewNetworkEmitBattle. _notEmit short-
// circuits the byte-push before any socket I/O; the shadow engine never originates a send anyway.
// NOTE: this is a process-global; one engine per process is assumed for the shadow (revisit for
// live multi-session — see design O-N status). Idempotent enough for the per-battle setup.
private static void InstallHeadlessNetworkAgent()
{
var agent = (RealTimeNetworkAgent)FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared);
SetField(agent, "_gungnir", FormatterServices.GetUninitializedObject(typeof(Gungnir)));
SetProperty(agent, "NetworkLogger", new NetworkNullLogger());
SetField(agent, "_notEmit", true);
ToolboxGame.SetRealTimeNetworkBattle(agent);
}
// Write the two seats' class ordinals into GameMgr's DataMgr leader chara ids. Mirrors the test
// seam HeadlessFixture.cs:202-204 (SetField(dm, "_playerCharaId"/"_enemyCharaId", ...)). chara_id ==
// class_id for 1..8 in EngineGlobalInit's all-8-class ClassCharacterList, so the ordinal selects the
// class. A non-positive ordinal (e.g. CardClass.None == 0) clamps to the default seat (1/2).
// GameMgr is a process global → safe only under EngineSessionGate (one engine-backed battle at a
// time).
private static void SetGameMgrCharaIds(int a, int b)
{
var dm = GameMgr.GetIns().GetDataMgr();
SetField(dm, "_playerCharaId", a <= 0 ? 1 : a);
SetField(dm, "_enemyCharaId", b <= 0 ? 2 : b);
}
private static void SetField(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}'");
f.SetValue(obj, value);
}
private static void SetProperty(object obj, string name, object value)
{
var t = obj.GetType();
PropertyInfo? p = null;
while (t is not null && p is null)
{
p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
t = t.BaseType;
}
(p ?? throw new InvalidOperationException($"{obj.GetType().Name} has no property '{name}'"))
.SetValue(obj, value);
}
private static void TrySetProperty(object obj, string name, object value)
{
var t = obj.GetType();
while (t is not null)
{
var p = t.GetProperty(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (p is not null) { p.SetValue(obj, value); return; }
t = t.BaseType;
}
}
}