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() {