using SVSim.BattleNode.Protocol; 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 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 = (CardOwner)(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 new MinedToken((int)AsLong(i), cardId, isSelf); } } /// Mine choice/Discover-token identities: for each isChoice add op (idx, isSelf, /// candidates), resolve its cardId from the keyAction selectCard pick whose cardId is in that /// op's candidate pool. Yields (idx, cardId, isSelf) — same shape as , /// routed by the same rule. The pick is on /// keyAction.selectCard, NOT the add op (RegisterChoiceAdd strips the concrete cardId, /// NetworkBattleSetupCardEvent.cs:531-543); the candidate-membership join handles the single /// case unambiguously (multi-choice: each chosen cardId matches the one choiceAdd whose candidates /// contain it). type/cardId/open on the keyAction are ignored here — open /// only gates the strip (), not the recording. An add whose /// candidates contain none of the picks is skipped (defensive — no record, no desync); Echo (no /// keyAction) yields nothing, leaving it mining-only via . public static IEnumerable MineChoicePicks(object? orderList, object? keyAction) { if (orderList is not IEnumerable ops) yield break; // Flatten every selectCard.cardId pick across all keyAction entries into a membership set. var picks = new HashSet(); if (keyAction is IEnumerable kaEntries) { foreach (var ka in kaEntries) { if (ka is not IDictionary kaDict) continue; if (!kaDict.TryGetValue("selectCard", out var scRaw) || scRaw is not IDictionary sc) continue; if (!sc.TryGetValue("cardId", out var idsRaw) || idsRaw is not IEnumerable ids) continue; foreach (var id in ids) picks.Add(AsLong(id)); } } if (picks.Count == 0) 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; if (!add.ContainsKey("isChoice")) continue; if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary card) continue; if (!card.TryGetValue("candidates", out var candRaw) || candRaw is not IEnumerable candidates) continue; // The chosen cardId is the candidate that the active player picked (∈ picks). One per op. long? chosen = null; foreach (var c in candidates) { var cid = AsLong(c); if (picks.Contains(cid)) { chosen = cid; break; } } if (chosen is null) continue; // no pick in this op's pool — skip (no desync, just no record) add.TryGetValue("isSelf", out var isSelfRaw); var isSelf = (CardOwner)(int)AsLong(isSelfRaw); if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; foreach (var i in idxList) yield return new MinedToken((int)AsLong(i), chosen.Value, isSelf); } } /// Mine copy/clone-token identities: for each copy add op /// ({idx:[...], isSelf, card:{baseIdx, isPremium}}), resolve its cardId from the appropriate /// side's idx->cardId map. The copied card lives at baseIdx in the actor's OWN index space — /// RegisterCopyToken is emitted only for !IsReferenceOpponenCard /// (NetworkBattleManagerBase.cs:1106); a cross-side copy sends a concrete cardId via a /// plain RegisterToken instead (handled by ). Yields /// (idx, cardId, isSelf) — same shape as , routed by the same /// rule: isSelf:1 resolves+records into the /// sender's map (), isSelf:0 into the opponent's /// (). Skips an add with a concrete cardId (→ MineAddOps), one with /// candidates (→ MineChoicePicks), a string baseIdx (private-group copy, /// RegisterCopyToken.cs:19-22), and a baseIdx absent from the chosen map (unknown source /// → degrade, no desync). isPremium (IsFoil) is cosmetic and ignored. public static IEnumerable MineCopyTokens( object? orderList, IReadOnlyDictionary selfMap, IReadOnlyDictionary otherMap) { 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; if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary card) continue; if (card.ContainsKey("cardId")) continue; // concrete token → MineAddOps if (!card.TryGetValue("baseIdx", out var baseRaw)) continue; // not a copy (candidates → MineChoicePicks) if (baseRaw is string) continue; // private-group copy → string baseIdx, skip var baseIdx = (int)AsLong(baseRaw); add.TryGetValue("isSelf", out var isSelfRaw); var isSelf = (CardOwner)(int)AsLong(isSelfRaw); var map = isSelf == CardOwner.Self ? selfMap : otherMap; if (!map.TryGetValue(baseIdx, out var cardId)) continue; // unknown source → degrade if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; foreach (var i in idxList) yield return new MinedToken((int)AsLong(i), cardId, isSelf); } } /// Map an inbound keyAction (the active player's send) to the opponent-facing list: /// for each Choice(1)/HaveBeforeSkillChoice(5) entry, keep {type,cardId} and drop /// selectCard when its open==0 (hidden draw-to-hand pick stays secret), pass it /// through when open==1 (visible board choice — provisional reveal-immediately, §6). /// Non-choice KeyActionTypes are dropped (current behavior) until their own specs. Returns null /// for absent/empty keyAction or when every entry was dropped (vanilla play unchanged). public static IReadOnlyList? StripKeyActionForOpponent(object? keyAction) { if (keyAction is not IEnumerable entries) return null; var result = new List(); foreach (var e in entries) { if (e is not IDictionary d) continue; d.TryGetValue("type", out var typeRaw); var type = (KeyActionType)(int)AsLong(typeRaw); if (type is not (KeyActionType.Choice or KeyActionType.HaveBeforeSkillChoice)) continue; d.TryGetValue("cardId", out var cardIdRaw); var cardId = AsLong(cardIdRaw); SelectCardEntry? selectCard = null; if (d.TryGetValue("selectCard", out var scRaw) && scRaw is IDictionary sc) { sc.TryGetValue("open", out var openRaw); var open = (ChoiceVisibility)(int)AsLong(openRaw); if (open != ChoiceVisibility.Hidden && sc.TryGetValue("cardId", out var idsRaw) && idsRaw is IEnumerable ids) selectCard = new SelectCardEntry(ids.Select(AsLong).ToList(), open); } result.Add(new KeyActionEntry(type, cardId, selectCard)); } return result.Count == 0 ? null : result; } /// 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: (CardOwner)(int)AsLong(isSelfRaw))); } return result.Count == 0 ? null : result; } /// Map the sender's uList (unapproved-movement list) to the opponent-facing /// list, VERBATIM — the node makes no reveal decision; it forwards /// whatever the sender emitted (cardId present = the sender chose to reveal). The five always-present /// fields (idxList/from/to/isSelf/skill) map directly; the conditionals map only when their key is /// present (mirroring the emitter, SendCardDataMaker.MakeUList:188-244). Null for an /// absent/empty list (mirrors ). isSelf/place-states pass through unchanged /// (F2; same verbatim assumption already shipped for the synthesized knownList). public static IReadOnlyList? RelayUList(object? uList) { if (uList is not IEnumerable entries) return null; var result = new List(); foreach (var e in entries) { if (e is not IDictionary d) continue; d.TryGetValue("idxList", out var idxRaw); d.TryGetValue("from", out var fromRaw); d.TryGetValue("to", out var toRaw); d.TryGetValue("isSelf", out var isSelfRaw); d.TryGetValue("skill", out var skillRaw); result.Add(new UnapprovedCardEntry( IdxList: AsIntList(idxRaw) ?? new List(), From: (int)AsLong(fromRaw), To: (int)AsLong(toRaw), IsSelf: (CardOwner)(int)AsLong(isSelfRaw), Skill: skillRaw as string ?? "", CardId: d.TryGetValue("cardId", out var c) ? AsLong(c) : null, Clan: d.TryGetValue("clan", out var cl) ? (int)AsLong(cl) : null, Cost: d.TryGetValue("cost", out var co) ? (int)AsLong(co) : null, SkillKeyCardIdx: AsIntList(d.TryGetValue("skillKeyCardIdx", out var sk) ? sk : null), RandomTargetIdx: AsIntList(d.TryGetValue("randomTargetIdx", out var rt) ? rt : null), IsInvoke: d.TryGetValue("isInvoke", out var iv) ? AsLong(iv) != 0 : null, AttachTarget: d.TryGetValue("attachTarget", out var at) ? at as string : null)); } return result.Count == 0 ? null : result; } /// Coerce a boxed RawBody list leaf to List<int> (each element via /// ); null when the value isn't a list. private static IReadOnlyList? AsIntList(object? value) => value is IEnumerable items ? items.Select(i => (int)AsLong(i)).ToList() : null; /// 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, }; }