fix(battlenode): emit real spellboost count in played-card knownList
The node hardcoded knownList.spellboost=0 on every played card. Prod sends the true accumulated count, which the client reads straight into the card's cost model; with 0 the opponent computes the card at full price and silently rejects the play in OperateReceiveChecker.IsPlayCard (PP-over -> ConductError -> NullOperationCollection -> no render/echo), desyncing the board. Mine spellboost-count changes from the sender''s orderList alter ops (MineAlterSpellboosts: a/s/h ops), accumulate per-side idx->count in BattleSessionState (RecordSpellboostFrom), and surface the current count on the played card via BuildPlayedCard. Recorded from the authoritative PlayActions only (never the Echo) and folded in AFTER the played card is built, since a card''s cost is fixed as it leaves hand and a play that grants spellboost targets the rest of the hand. Also adds a [sio-in-body] full-body inbound log to RealParticipant to capture both clients'' re-simulated responses for PvP RNG verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,15 +10,53 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
internal static class KnownListBuilder
|
||||
{
|
||||
/// <summary>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).</summary>
|
||||
/// (token idx not in the deck map, or no matching move op). <paramref name="spellboostMap"/> supplies
|
||||
/// the played card's spellboost COUNT (accumulated from prior <c>alter</c> ops via
|
||||
/// <see cref="MineAlterSpellboosts"/> / <c>BattleSessionState.RecordSpellboostFrom</c>); absent/unmapped
|
||||
/// idx → 0. Prod sends the real count here and the client reads it straight into the card's cost model
|
||||
/// (<c>NetworkBattleReceiver</c> spellboost case), so a wrong value makes the opponent compute the
|
||||
/// card at full price and silently reject the play in <c>OperateReceiveChecker.IsPlayCard</c>
|
||||
/// (PP-over → ConductError → NullOperationCollection → no render/echo). attachTarget stays "";
|
||||
/// cost/clan/tribe remain deferred (receiver re-derives from cardId).</summary>
|
||||
public static KnownCardEntry? BuildPlayedCard(
|
||||
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList)
|
||||
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList,
|
||||
IReadOnlyDictionary<int, int>? spellboostMap = null)
|
||||
{
|
||||
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: "");
|
||||
var spellboost = spellboostMap is not null && spellboostMap.TryGetValue(playIdx, out var sb) ? sb : 0;
|
||||
return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: spellboost, AttachTarget: "");
|
||||
}
|
||||
|
||||
/// <summary>Mine spellboost-count changes from a sender's <c>orderList</c> <c>alter</c> ops. For each
|
||||
/// <c>{alter:{idx:[...], isSelf, spellboost:"<op><n>"}}</c> op, yields
|
||||
/// <c>(idx, isSelf, op, amount)</c> for every idx — <c>op</c> ∈ {<c>'a'</c> add, <c>'s'</c> set,
|
||||
/// <c>'h'</c> half} (mirrors <c>RegisterAlter.ChangeType</c>; the leading letter on the value encodes
|
||||
/// the operation, the rest is the integer amount). <c>isSelf</c> is the sender's perspective tag,
|
||||
/// surfaced verbatim so the caller routes into the correct side's map (same rule as
|
||||
/// <see cref="MineAddOps"/>). Skips alter ops with no <c>spellboost</c> key (an alter can also carry
|
||||
/// cost/atk/etc.), a non-string or too-short value, an unparseable amount, or a non-list <c>idx</c>
|
||||
/// (e.g. a private-group string idx). The only form seen in real captures is <c>"a1"</c> (each spell
|
||||
/// play adds 1 to the listed hand cards); set/half are handled for completeness.</summary>
|
||||
public static IEnumerable<(int Idx, CardOwner IsSelf, char Op, int Amount)> MineAlterSpellboosts(object? orderList)
|
||||
{
|
||||
if (orderList is not IEnumerable<object?> ops) yield break;
|
||||
foreach (var op in ops)
|
||||
{
|
||||
if (op is not IDictionary<string, object?> opDict) continue;
|
||||
if (!opDict.TryGetValue(WireKeys.Alter, out var alterRaw) || alterRaw is not IDictionary<string, object?> alter) continue;
|
||||
if (!alter.TryGetValue(WireKeys.Spellboost, out var sbRaw) || sbRaw is not string sbStr || sbStr.Length < 2) continue;
|
||||
var opChar = sbStr[0];
|
||||
if (!int.TryParse(sbStr.AsSpan(1), out var amount)) continue;
|
||||
|
||||
alter.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
|
||||
var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
|
||||
|
||||
if (!alter.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
||||
foreach (var i in idxList)
|
||||
yield return ((int)AsLong(i), isSelf, opChar, amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The <c>to</c> place-state of the FIRST <c>move</c> op whose <c>idx</c> list contains
|
||||
|
||||
Reference in New Issue
Block a user