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