From 13f902ce5897cae641d82138f6e930fe55e7a89f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 5 Jun 2026 13:51:40 -0400 Subject: [PATCH] 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 --- .../Sessions/Dispatch/BattleSessionState.cs | 42 +++++++++++++ .../Dispatch/Handlers/PlayActionsHandler.cs | 7 ++- .../Sessions/Dispatch/KnownListBuilder.cs | 46 ++++++++++++-- .../Sessions/Dispatch/WireKeys.cs | 2 + .../Sessions/Participants/RealParticipant.cs | 13 ++++ .../Sessions/BattleSessionStateTests.cs | 43 +++++++++++++ .../Sessions/KnownListBuilderTests.cs | 63 +++++++++++++++++++ 7 files changed, 211 insertions(+), 5 deletions(-) diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs index 30b100d..c8a7d42 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs @@ -104,6 +104,48 @@ internal sealed class BattleSessionState RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId); } + /// Per-side idx->spellboost COUNT, accumulated from orderList alter ops via + /// . Separate from because spellboost is a + /// mutable counter, not an identity. Surfaced by BuildPlayedCard as the played card's + /// knownList.spellboost so the opponent computes its discounted cost (see that method). + public Dictionary> IdxToSpellboost { get; } = new(); + + private Dictionary SpellboostMap(IBattleParticipant side) + { + if (!IdxToSpellboost.TryGetValue(side, out var map)) + IdxToSpellboost[side] = map = new Dictionary(); + return map; + } + + /// The side's idx->spellboost map (empty if nothing recorded yet). Read by + /// PlayActionsHandler to feed BuildPlayedCard. + public IReadOnlyDictionary GetSpellboostMap(IBattleParticipant side) => SpellboostMap(side); + + /// Apply a frame's spellboost alter ops to the per-side maps. Routed by isSelf + /// (the sender's perspective) exactly like : isSelf:1 → the + /// sender's own hand (); isSelf:0 → the opponent's hand + /// () for the rare cross-side spellboost. Ops: 'a' add, 's' set, + /// 'h' half. Call this AFTER BuildPlayedCard for the same frame: a card's cost is fixed + /// when it leaves hand, so the played card's emitted count must reflect state BEFORE this frame's + /// grant (Fate's Hand plays, then spellboosts the rest of the hand). Recorded only from the + /// authoritative PlayActions, never the Echo, to avoid double-counting the same alter. + /// Known gap: a card bounced back to hand keeps its stale count (no reset on zone-exit) — not yet + /// observed in capture, left for when a bounce desync actually shows up. + public void RecordSpellboostFrom(IBattleParticipant from, IBattleParticipant other, object? orderList) + { + foreach (var (idx, isSelf, op, amount) in KnownListBuilder.MineAlterSpellboosts(orderList)) + { + var map = SpellboostMap(isSelf == CardOwner.Self ? from : other); + map.TryGetValue(idx, out var cur); + map[idx] = op switch + { + 's' => amount, // set + 'h' => cur / 2, // half + _ => cur + amount, // 'a' add (the only form seen in capture) + }; + } + } + /// Mine + record copy/clone-token identities () /// into the correct side's map. A copy's source lives at baseIdx in the actor's own index /// space, so the resolution side == the record side, both selected by the same isSelf routing diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index 3a5eeaf..b668119 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -39,7 +39,12 @@ internal sealed class PlayActionsHandler : IFrameHandler ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList); var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From); - var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList); + // 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, diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs index 4d4a2f7..010afde 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs @@ -10,15 +10,53 @@ namespace SVSim.BattleNode.Sessions.Dispatch; internal static class KnownListBuilder { /// 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). + /// (token idx not in the deck map, or no matching move op). supplies + /// the played card's spellboost COUNT (accumulated from prior alter ops via + /// / BattleSessionState.RecordSpellboostFrom); absent/unmapped + /// idx → 0. Prod sends the real count here and the client reads it straight into the card's cost model + /// (NetworkBattleReceiver spellboost case), so a wrong value makes the opponent compute the + /// card at full price and silently reject the play in OperateReceiveChecker.IsPlayCard + /// (PP-over → ConductError → NullOperationCollection → no render/echo). attachTarget stays ""; + /// cost/clan/tribe remain deferred (receiver re-derives from cardId). public static KnownCardEntry? BuildPlayedCard( - IReadOnlyDictionary deckMap, int playIdx, object? orderList) + IReadOnlyDictionary deckMap, int playIdx, object? orderList, + IReadOnlyDictionary? 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: ""); + } + + /// Mine spellboost-count changes from a sender's orderList alter ops. For each + /// {alter:{idx:[...], isSelf, spellboost:"<op><n>"}} op, yields + /// (idx, isSelf, op, amount) for every idx — op ∈ {'a' add, 's' set, + /// 'h' half} (mirrors RegisterAlter.ChangeType; the leading letter on the value encodes + /// the operation, the rest is the integer amount). isSelf is the sender's perspective tag, + /// surfaced verbatim so the caller routes into the correct side's map (same rule as + /// ). Skips alter ops with no spellboost key (an alter can also carry + /// cost/atk/etc.), a non-string or too-short value, an unparseable amount, or a non-list idx + /// (e.g. a private-group string idx). The only form seen in real captures is "a1" (each spell + /// play adds 1 to the listed hand cards); set/half are handled for completeness. + public static IEnumerable<(int Idx, CardOwner IsSelf, char Op, int Amount)> MineAlterSpellboosts(object? orderList) + { + if (orderList is not IEnumerable ops) yield break; + foreach (var op in ops) + { + if (op is not IDictionary opDict) continue; + if (!opDict.TryGetValue(WireKeys.Alter, out var alterRaw) || alterRaw is not IDictionary 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 idxList) continue; + foreach (var i in idxList) + yield return ((int)AsLong(i), isSelf, opChar, amount); + } } /// The to place-state of the FIRST move op whose idx list contains diff --git a/SVSim.BattleNode/Sessions/Dispatch/WireKeys.cs b/SVSim.BattleNode/Sessions/Dispatch/WireKeys.cs index 97b0b3f..a2e157e 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/WireKeys.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/WireKeys.cs @@ -29,6 +29,8 @@ internal static class WireKeys public const string Candidates = "candidates"; public const string IsChoice = "isChoice"; public const string BaseIdx = "baseIdx"; + public const string Alter = "alter"; + public const string Spellboost = "spellboost"; // keyAction.selectCard keys public const string SelectCard = "selectCard"; diff --git a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs index ed82666..36ae2f4 100644 --- a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs +++ b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs @@ -289,6 +289,19 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase _log.LogInformation( "[sio-in] viewer={Vid} uri={Uri} pubSeq={Pseq} ackId={AckId} dispatch={Dispatch} ackSent={AckSent} ackArg={AckArg} highWaterMark={Hwm}", ViewerId, env.Uri, env.PubSeq, frame.AckId, shouldDispatch, ackSent, ackArg, Inbound.HighWaterMark); + + // Full inbound body (orderList/targetList/uList/rand/...). Each client's re-simulation + // of the opponent's play arrives here as its own inbound frame, so this lets a PvP log + // show the caster's play and BOTH clients' re-rolled responses side by side — the + // ground truth for "did the opponent reproduce the RNG draw?" that frame metadata alone + // can't answer. Serialize defensively: a logging failure must never kill the read loop. + string body; + try { body = MsgEnvelope.ToJson(env); } + catch (Exception ex) { body = $""; } + if (body.Length > 4000) body = body.Substring(0, 4000) + "...(truncated)"; + _log.LogInformation( + "[sio-in-body] viewer={Vid} uri={Uri} pubSeq={Pseq} body={Body}", + ViewerId, env.Uri, env.PubSeq, body); } if (!shouldDispatch) return; diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs index 1958ca9..ea7c463 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs @@ -88,6 +88,49 @@ public class BattleSessionStateTests Assert.That(a, Is.Not.EqualTo(b)); } + [Test] + public void RecordSpellboostFrom_accumulates_add_ops_and_routes_by_isSelf() + { + var state = new BattleSessionState(masterSeed: 1); + var caster = new StubParticipant(7, Ctx(900L)); + var oppo = new StubParticipant(6, Ctx(901L)); + + // Two spell-plays each grant +1 to the caster's hand card idx 3 (the classic spellboost ramp). + var grant = new List { new Dictionary + { ["alter"] = new Dictionary + { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["type"] = "add", ["spellboost"] = "a1" } } }; + state.RecordSpellboostFrom(caster, oppo, grant); + state.RecordSpellboostFrom(caster, oppo, grant); + + Assert.That(state.GetSpellboostMap(caster)[3], Is.EqualTo(2), "two +1 grants accumulate"); + Assert.That(state.GetSpellboostMap(oppo).ContainsKey(3), Is.False, "isSelf:1 routes to the caster only"); + } + + [Test] + public void Boosted_card_carries_real_spellboost_into_its_knownList() + { + // End-to-end regression for the 2026-06-05 desync: a spellboosted card whose relayed knownList + // shipped spellboost:0 made the opponent compute full cost and silently reject the play. After the + // grant is recorded, BuildPlayedCard must emit the accumulated count, not 0. + var state = new BattleSessionState(masterSeed: 1); + var caster = new StubParticipant(7, Ctx(101311010L, 999L, 998L)); // idx 1..3 deck-seeded + + var grant = new List { new Dictionary + { ["alter"] = new Dictionary + { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["type"] = "add", ["spellboost"] = "a1" } } }; + state.RecordSpellboostFrom(caster, new StubParticipant(6, Ctx(901L)), grant); + + // idx 3 is now played hand->board; its knownList must reflect the +1. + var play = new List { new Dictionary + { ["move"] = new Dictionary + { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L } } }; + var entry = KnownListBuilder.BuildPlayedCard( + state.GetOrSeedDeckMap(caster), playIdx: 3, orderList: play, state.GetSpellboostMap(caster)); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Spellboost, Is.EqualTo(1)); + } + private static long[] DistinctDeck() => Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToArray(); } diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index a40f7fe..cb9ca43 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -92,6 +92,69 @@ public class KnownListBuilderTests Assert.That(entry, Is.Null); } + // A spellboost alter op as it arrives in a RawBody: { "alter": { "idx": [..], "isSelf": n, + // "type": "add", "spellboost": "a1" } } — the value's leading letter is the op, the rest the amount. + private static List AlterSpellboostOp(long[] idxs, string value, long isSelf = 1) => new() + { + new Dictionary + { + ["alter"] = new Dictionary + { + ["idx"] = idxs.Select(i => (object?)i).ToList(), + ["isSelf"] = isSelf, ["type"] = "add", ["spellboost"] = value, + } + } + }; + + [Test] + public void BuildPlayedCard_emits_spellboost_count_from_map() + { + // The fix: a boosted card's knownList must carry its real count (prod sends 1/2/3), not 0. + var deckMap = new Dictionary { [3] = 101311010L }; + var spellboost = new Dictionary { [3] = 2 }; + var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), spellboost); + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Spellboost, Is.EqualTo(2)); + } + + [Test] + public void BuildPlayedCard_defaults_spellboost_to_zero_when_idx_unmapped_or_no_map() + { + var deckMap = new Dictionary { [3] = 101311010L }; + var otherIdx = new Dictionary { [9] = 4 }; + Assert.That(KnownListBuilder.BuildPlayedCard(deckMap, 3, OrderListMove(3, 10, 20), otherIdx)!.Spellboost, Is.EqualTo(0)); + Assert.That(KnownListBuilder.BuildPlayedCard(deckMap, 3, OrderListMove(3, 10, 20))!.Spellboost, Is.EqualTo(0)); + } + + [Test] + public void MineAlterSpellboosts_yields_op_and_amount_for_every_idx() + { + var mined = KnownListBuilder.MineAlterSpellboosts(AlterSpellboostOp(new[] { 3L, 41L, 42L }, "a1")).ToList(); + Assert.That(mined.Select(m => m.Idx), Is.EquivalentTo(new[] { 3, 41, 42 })); + Assert.That(mined.All(m => m.IsSelf == CardOwner.Self && m.Op == 'a' && m.Amount == 1), Is.True); + } + + [Test] + public void MineAlterSpellboosts_routes_cross_side_with_isSelf_0_and_parses_set() + { + var mined = KnownListBuilder.MineAlterSpellboosts(AlterSpellboostOp(new[] { 5L }, "s3", isSelf: 0)).Single(); + Assert.That(mined.IsSelf, Is.EqualTo(CardOwner.Opponent)); + Assert.That(mined.Op, Is.EqualTo('s')); + Assert.That(mined.Amount, Is.EqualTo(3)); + } + + [Test] + public void MineAlterSpellboosts_skips_alters_without_spellboost_and_null() + { + var costAlter = new List + { + new Dictionary { ["alter"] = new Dictionary + { ["idx"] = new List { 3L }, ["isSelf"] = 1L, ["cost"] = "s1" } }, + }; + Assert.That(KnownListBuilder.MineAlterSpellboosts(costAlter), Is.Empty); + Assert.That(KnownListBuilder.MineAlterSpellboosts(null), Is.Empty); + } + [Test] public void RenameTargets_passes_isSelf_through_verbatim() {