using SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Sessions.Dispatch; /// Pure transforms from the active player's RawBody sub-structures to the opponent-facing /// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive /// as Dictionary<string,object?> / List<object?> with numeric leaves boxed /// as long/int/double (see MsgEnvelope.FromJson). internal static class KnownListBuilder { /// The played card's knownList entry, or null when its identity can't be synthesized /// (token idx not in the deck map, or no matching move op). spellboost/attachTarget default to /// 0/"" for the vanilla slice; cost/clan/tribe are deferred (receiver re-derives from cardId). public static KnownCardEntry? BuildPlayedCard( IReadOnlyDictionary deckMap, int playIdx, object? orderList) { if (!deckMap.TryGetValue(playIdx, out var cardId)) return null; var to = ExtractMoveTo(orderList, playIdx); if (to is null) return null; return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: 0, AttachTarget: ""); } /// The to place-state of the FIRST move op whose idx list contains /// (the played card's own move; later add/alter ops are the deferred /// token slice), or null if absent. NOTE: the sender-side to is passed through verbatim — /// for the vanilla slice we assume send-side and recv-side place-state codes match, pending /// recv-capture confirmation. public static int? ExtractMoveTo(object? orderList, int playIdx) { if (orderList is not IEnumerable ops) return null; foreach (var op in ops) { if (op is not IDictionary opDict) continue; if (!opDict.TryGetValue("move", out var moveRaw) || moveRaw is not IDictionary move) continue; if (move.TryGetValue("idx", out var idxRaw) && idxRaw is IEnumerable idxList) { foreach (var i in idxList) if (AsLong(i) == playIdx && move.TryGetValue("to", out var toRaw)) return (int)AsLong(toRaw); } } return null; } /// Mine generated-token identities from a sender's add ops: yields /// (idx, cardId, isSelf) for every idx in each {add:{idx:[...], isSelf, card:{cardId}}} /// op. isSelf is surfaced verbatim (the sender's perspective tag on CardObj.IsPlayer, /// RegisterToken.cs:22) so the caller can route the identity into the correct side's map — /// isSelf:1 = the sender's own token, isSelf:0 = a cross-side gift living at this idx /// in the OPPONENT's index space (). Skips any add /// whose card has no concrete cardId — choice tokens (card:{candidates}, /// RegisterChoiceAdd), copy tokens (card:{baseIdx}, RegisterCopyToken), and /// private-group adds (string idx) — all deferred and all caught by the cardId-key / /// idx-is-list guards. This is the only place a freshly-generated card's identity exists on /// the wire (bullet-3 audit F1; producing code RegisterToken/RegisterActionBase) — /// the played-card op itself never carries a cardId. public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineAddOps(object? orderList) { if (orderList is not IEnumerable ops) yield break; foreach (var op in ops) { if (op is not IDictionary opDict) continue; if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary add) continue; add.TryGetValue("isSelf", out var isSelfRaw); var isSelf = (int)AsLong(isSelfRaw); if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary card) continue; if (!card.TryGetValue("cardId", out var cardIdRaw)) continue; // candidates/isChoice → no identity yet var cardId = AsLong(cardIdRaw); if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; foreach (var i in idxList) yield return ((int)AsLong(i), cardId, isSelf); } } /// Rename targetList -> oppoTargetList; isSelf is actor-relative /// and passes through unchanged (F2). Null for a missing/empty list. public static IReadOnlyList? RenameTargets(object? targetList) { if (targetList is not IEnumerable entries) return null; var result = new List(); foreach (var e in entries) { if (e is not IDictionary d) continue; d.TryGetValue("targetIdx", out var targetIdxRaw); d.TryGetValue("isSelf", out var isSelfRaw); result.Add(new OppoTargetEntry( TargetIdx: (int)AsLong(targetIdxRaw), IsSelf: (int)AsLong(isSelfRaw))); } return result.Count == 0 ? null : result; } /// Coerce a boxed RawBody numeric leaf (long/int/double/decimal/string) to long; 0 for /// null/unparseable. public static long AsLong(object? value) => value switch { long l => l, int i => i, double d => (long)d, decimal m => (long)m, string s when long.TryParse(s, out var p) => p, _ => 0, }; }