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 orderListalter 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 orderListalter 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