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>
70 lines
4.3 KiB
C#
70 lines
4.3 KiB
C#
using SVSim.BattleNode.Protocol;
|
|
using SVSim.BattleNode.Protocol.Bodies;
|
|
|
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
|
|
|
/// <summary>PvP PlayActions translator. Synthesizes the opponent-facing knownList from the sender's
|
|
/// idx->cardId map + the orderList move op, renames targetList -> oppoTargetList, drops orderList,
|
|
/// and forwards a stripped keyAction for choice/Discover plays ({type,cardId}; selectCard dropped
|
|
/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens),
|
|
/// keyAction.selectCard (choice picks), or a baseIdx copy resolved against the side's map — all mined
|
|
/// on earlier (or the same) frames; an un-generated token idx still degrades to {playIdx,type}
|
|
/// (no knownList). Bot drop (no rule).</summary>
|
|
internal sealed class PlayActionsHandler : IFrameHandler
|
|
{
|
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
|
{
|
|
if (!ctx.BothSidesAfterReady())
|
|
return Array.Empty<DispatchRoute>();
|
|
|
|
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();
|
|
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.PlayIdx));
|
|
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.Type));
|
|
|
|
var orderList = entries.GetValueOrDefault(WireKeys.OrderList);
|
|
var keyAction = entries.GetValueOrDefault(WireKeys.KeyAction);
|
|
|
|
// Mine generated-token identities from this frame's add ops into the right side's idx->cardId
|
|
// map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER
|
|
// frame resolves its cardId — by whichever side ends up playing it (bullet-3 audit F1).
|
|
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
|
|
|
|
// Choice/Discover-into-hand: the chosen cardId rides keyAction.selectCard (the orderList's
|
|
// choiceAdd carries candidates only). Record idx->chosenCardId now so the later play reveals it.
|
|
ctx.State.RecordChoicePicksFrom(ctx.From, ctx.Other, orderList, keyAction);
|
|
|
|
// Copy/clone tokens: card:{baseIdx} points at a card in the actor's own index space; resolve it
|
|
// against that side's map and record copyIdx->cardId so the later play reveals it. Ordered after
|
|
// the plain/choice mining so a same-frame copy of a just-added token resolves against the live map.
|
|
ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
|
|
|
|
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
|
// Spellboost count rides the played card's knownList (prod-faithful; the client reads it into the
|
|
// card's cost model). Read the CURRENT map (state before this frame's grant) for the emit, then
|
|
// fold THIS frame's alter ops in afterwards — a card's cost is fixed as it leaves hand, and a play
|
|
// that grants spellboost (e.g. Fate's Hand) targets the REST of the hand, not the card just played.
|
|
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList, ctx.State.GetSpellboostMap(ctx.From));
|
|
ctx.State.RecordSpellboostFrom(ctx.From, ctx.Other, orderList);
|
|
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault(WireKeys.TargetList));
|
|
|
|
// Deck-sourced movements (fetch / search / summon-from-deck) ride the uList — a verbatim,
|
|
// separate receive slot the node forwards unchanged (bullet-3 audit F1). The node makes no
|
|
// reveal decision; cardId presence is the sender's call. Coexists with the synthesized
|
|
// knownList in the same frame (capture line 75).
|
|
var uList = KnownListBuilder.RelayUList(entries.GetValueOrDefault(WireKeys.UList));
|
|
|
|
var body = new PlayActionsBroadcastBody(
|
|
PlayIdx: playIdx,
|
|
Type: type,
|
|
KnownList: played is null ? null : new[] { played },
|
|
OppoTargetList: oppoTargets,
|
|
UList: uList,
|
|
// {type,cardId} forwarded so the opponent renders the choice token; selectCard dropped
|
|
// when open==0 (hidden draw-to-hand pick). Null for a vanilla play (no keyAction).
|
|
KeyAction: KnownListBuilder.StripKeyActionForOpponent(keyAction));
|
|
|
|
var frame = ctx.Env with { Body = body };
|
|
return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
|
|
}
|
|
}
|