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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 11:17:10 -04:00
parent 61080adace
commit c0309061fa
3 changed files with 146 additions and 0 deletions

View File

@@ -47,3 +47,23 @@ public sealed record KnownCardEntry(
public sealed record OppoTargetEntry(
[property: JsonPropertyName("targetIdx")] int TargetIdx,
[property: JsonPropertyName("isSelf")] int IsSelf);
/// <summary>One entry in a relayed <c>uList</c> (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 — <c>cardId</c> presence is the sender's
/// call). The first five fields are always emitted; the rest are conditional in
/// <c>SendCardDataMaker.MakeUList</c> (cardId when revealed, clan/cost when set, etc.) and omit when
/// null. <c>isSelf</c> is actor-relative and passes through unchanged (F2).</summary>
public sealed record UnapprovedCardEntry(
[property: JsonPropertyName("idxList")] IReadOnlyList<int> 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<int>? SkillKeyCardIdx = null,
[property: JsonPropertyName("randomTargetIdx")] IReadOnlyList<int>? RandomTargetIdx = null,
[property: JsonPropertyName("isInvoke")] int? IsInvoke = null,
[property: JsonPropertyName("attachTarget")] string? AttachTarget = null);

View File

@@ -222,6 +222,49 @@ internal static class KnownListBuilder
return result.Count == 0 ? null : result;
}
/// <summary>Map the sender's <c>uList</c> (unapproved-movement list) to the opponent-facing
/// <see cref="UnapprovedCardEntry"/> 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, <c>SendCardDataMaker.MakeUList:188-244</c>). Null for an
/// absent/empty list (mirrors <see cref="RenameTargets"/>). isSelf/place-states pass through unchanged
/// (F2; same verbatim assumption already shipped for the synthesized knownList).</summary>
public static IReadOnlyList<UnapprovedCardEntry>? RelayUList(object? uList)
{
if (uList is not IEnumerable<object?> entries) return null;
var result = new List<UnapprovedCardEntry>();
foreach (var e in entries)
{
if (e is not IDictionary<string, object?> 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<int>(),
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;
}
/// <summary>Coerce a boxed RawBody list leaf to <c>List&lt;int&gt;</c> (each element via
/// <see cref="AsLong"/>); null when the value isn't a list.</summary>
private static IReadOnlyList<int>? AsIntList(object? value) =>
value is IEnumerable<object?> items ? items.Select(i => (int)AsLong(i)).ToList() : null;
/// <summary>Coerce a boxed RawBody numeric leaf (long/int/double/decimal/string) to long; 0 for
/// null/unparseable.</summary>
public static long AsLong(object? value) => value switch

View File

@@ -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<int, long>()).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<string, object?> 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<object?> { 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<object?> { 7L };
entry["randomTargetIdx"] = new List<object?> { 2L, 3L };
entry["isInvoke"] = 1L;
entry["attachTarget"] = "12,13";
var relayed = KnownListBuilder.RelayUList(new List<object?> { 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<object?>
{
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<object?>()), Is.Null);
}
}