From c0309061fac96e6dcc697da967ebf63e1a4f447c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 4 Jun 2026 11:17:10 -0400 Subject: [PATCH] feat(battle-node): UnapprovedCardEntry + RelayUList pure transform Verbatim uList relay shape + transform (deck-sourced summons/fetches), mirroring RenameTargets. Not yet wired into the handler. Co-Authored-By: Claude Opus 4.8 --- .../Bodies/PlayActionsBroadcastBody.cs | 20 +++++ .../Sessions/Dispatch/KnownListBuilder.cs | 43 ++++++++++ .../Sessions/KnownListBuilderTests.cs | 83 +++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs index 22fac11..fcf477f 100644 --- a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs +++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs @@ -47,3 +47,23 @@ public sealed record KnownCardEntry( public sealed record OppoTargetEntry( [property: JsonPropertyName("targetIdx")] int TargetIdx, [property: JsonPropertyName("isSelf")] int IsSelf); + +/// One entry in a relayed uList (the unapproved-movement list) — a skill-driven +/// card movement (fetch / search / summon-from-deck / discard-reveal) the node forwards VERBATIM +/// (bullet-3 audit F1; the node makes no reveal decision — cardId presence is the sender's +/// call). The first five fields are always emitted; the rest are conditional in +/// SendCardDataMaker.MakeUList (cardId when revealed, clan/cost when set, etc.) and omit when +/// null. isSelf is actor-relative and passes through unchanged (F2). +public sealed record UnapprovedCardEntry( + [property: JsonPropertyName("idxList")] IReadOnlyList IdxList, + [property: JsonPropertyName("from")] int From, + [property: JsonPropertyName("to")] int To, + [property: JsonPropertyName("isSelf")] int IsSelf, + [property: JsonPropertyName("skill")] string Skill, + [property: JsonPropertyName("cardId")] long? CardId = null, + [property: JsonPropertyName("clan")] int? Clan = null, + [property: JsonPropertyName("cost")] int? Cost = null, + [property: JsonPropertyName("skillKeyCardIdx")] IReadOnlyList? SkillKeyCardIdx = null, + [property: JsonPropertyName("randomTargetIdx")] IReadOnlyList? RandomTargetIdx = null, + [property: JsonPropertyName("isInvoke")] int? IsInvoke = null, + [property: JsonPropertyName("attachTarget")] string? AttachTarget = null); diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs index 6d2cfe0..9ec92c0 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs @@ -222,6 +222,49 @@ internal static class KnownListBuilder 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: (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) ? (int)AsLong(iv) : 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 diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index cdbac0a..3133844 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions.Dispatch; namespace SVSim.UnitTests.BattleNode.Sessions; @@ -422,4 +423,86 @@ public class KnownListBuilderTests var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary()).ToList(); Assert.That(mined, Is.EquivalentTo(new[] { (31, 700L, 1), (32, 700L, 1) })); } + + // A uList entry as it arrives in a RawBody. Minimal = the 5 always-present fields + // (capture battle-traffic_tk2_regular.ndjson:75). Optional fields added per-test. + private static Dictionary UListEntry( + long[] idxList, int from, int to, int isSelf, string skill) => new() + { + ["idxList"] = idxList.Select(i => (object?)i).ToList(), + ["from"] = (long)from, ["to"] = (long)to, ["isSelf"] = (long)isSelf, ["skill"] = skill, + }; + + [Test] + public void RelayUList_maps_the_minimal_capture_entry_shape() + { + // battle-traffic_tk2_regular.ndjson:75 — a hidden deck-fetch (no cardId), the only uList shape + // in any capture. The 5 always-present fields map; conditionals stay null. + var uList = new List { UListEntry(new[] { 16L, 22L }, from: 0, to: 10, isSelf: 1, skill: "37|36|0") }; + var relayed = KnownListBuilder.RelayUList(uList); + + Assert.That(relayed, Is.Not.Null); + Assert.That(relayed!.Count, Is.EqualTo(1)); + var e = relayed[0]; + Assert.That(e.IdxList, Is.EqualTo(new[] { 16, 22 })); + Assert.That(e.From, Is.EqualTo(0)); + Assert.That(e.To, Is.EqualTo(10)); + Assert.That(e.IsSelf, Is.EqualTo(1)); + Assert.That(e.Skill, Is.EqualTo("37|36|0")); + Assert.That(e.CardId, Is.Null); + Assert.That(e.Clan, Is.Null); + Assert.That(e.Cost, Is.Null); + Assert.That(e.SkillKeyCardIdx, Is.Null); + Assert.That(e.RandomTargetIdx, Is.Null); + Assert.That(e.IsInvoke, Is.Null); + Assert.That(e.AttachTarget, Is.Null); + } + + [Test] + public void RelayUList_maps_a_revealed_summon_with_all_conditional_fields() + { + // Decomp-grounded (no capture): a revealed summon-to-field carries cardId + clan + cost etc. + var entry = UListEntry(new[] { 40L }, from: 0, to: 20, isSelf: 1, skill: "5|3|0"); + entry["cardId"] = 900111010L; + entry["clan"] = 8L; + entry["cost"] = 2L; + entry["skillKeyCardIdx"] = new List { 7L }; + entry["randomTargetIdx"] = new List { 2L, 3L }; + entry["isInvoke"] = 1L; + entry["attachTarget"] = "12,13"; + var relayed = KnownListBuilder.RelayUList(new List { entry }); + + var e = relayed![0]; + Assert.That(e.To, Is.EqualTo(20)); + Assert.That(e.CardId, Is.EqualTo(900111010L)); + Assert.That(e.Clan, Is.EqualTo(8)); + Assert.That(e.Cost, Is.EqualTo(2)); + Assert.That(e.SkillKeyCardIdx, Is.EqualTo(new[] { 7 })); + Assert.That(e.RandomTargetIdx, Is.EqualTo(new[] { 2, 3 })); + Assert.That(e.IsInvoke, Is.EqualTo(1)); + Assert.That(e.AttachTarget, Is.EqualTo("12,13")); + } + + [Test] + public void RelayUList_preserves_multiple_entries_in_order() + { + var uList = new List + { + UListEntry(new[] { 16L }, 0, 10, 1, "a"), + UListEntry(new[] { 22L }, 0, 20, 0, "b"), + }; + var relayed = KnownListBuilder.RelayUList(uList); + + Assert.That(relayed!.Count, Is.EqualTo(2)); + Assert.That(relayed[0].Skill, Is.EqualTo("a")); + Assert.That(relayed[1].Skill, Is.EqualTo("b")); + Assert.That(relayed[1].IsSelf, Is.EqualTo(0)); + } + + [Test] + public void RelayUList_returns_null_for_missing_or_empty() + { + Assert.That(KnownListBuilder.RelayUList(null), Is.Null); + Assert.That(KnownListBuilder.RelayUList(new List()), Is.Null); + } }