diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
index 0aef775..a1ada3e 100644
--- a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
+++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
@@ -39,9 +39,12 @@ public sealed record SelectCardEntry(
/// One revealed card in a knownList. cardId from the sender's deck map; cost
/// is the ENGINE-RESOLVED play-time cost (M-HC-3a) — the discounted cost the headless engine actually
/// charged (spellboost + board modifiers folded in by construction), emitted on EVERY entry (prod sends
-/// cost 45/45 in captures, so it is NOT omitted). spellboost still carries the count for now
-/// (Task 6 retires that bookkeeping once cost is engine-sourced everywhere). attachTarget stays "";
-/// clan/tribe remain deferred (receiver re-derives them from cardId).
+/// cost 45/45 in captures, so it is NOT omitted). spellboost is now ALSO engine-sourced (M-HC-3b) —
+/// the played card's accumulated spell-charge count read straight off the resolved engine
+/// (SessionBattleEngine.PlayedCardSpellboost); the wire-derived spellboost bookkeeping is retired.
+/// Cost already folds the discount in by construction; the count rides the entry only to stay prod-faithful
+/// (prod sends the real count). attachTarget stays ""; clan/tribe remain deferred (receiver re-derives
+/// them from cardId).
public sealed record KnownCardEntry(
[property: JsonPropertyName("idx")] int Idx,
[property: JsonPropertyName("cardId")] long CardId,
diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
index c8a7d42..30b100d 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
@@ -104,48 +104,6 @@ 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 289dc8d..af5a146 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs
@@ -48,13 +48,16 @@ internal sealed class PlayActionsHandler : IFrameHandler
bool senderSeat = ReferenceEquals(ctx.From, ctx.A);
int playedCost = ctx.Engine.PlayedCardCost(senderSeat, playIdx, fallback: 0);
- // Spellboost count still rides the played card's knownList (prod-faithful; Task 6 retires this
- // bookkeeping now that cost is engine-sourced). Read the CURRENT map (state before this frame's
- // grant) for the emit, then fold THIS frame's alter ops in afterwards — a play that grants
- // spellboost (e.g. Fate's Hand) targets the REST of the hand, not the card just played.
+ // The spellboost (spell-charge) COUNT is now ALSO engine-sourced (M-HC-3b) — the wire-derived
+ // bookkeeping is retired. The engine accumulated the true count for the played card during the
+ // ShadowIngest's engine.Receive (each spell play runs the card's own AddSpellChargeCount), so
+ // PlayedCardSpellboost reads it straight off the resolved card (persist-post-play, same zone search
+ // as the cost). Cost already folds the discount in by construction; the count rides the entry only
+ // to stay prod-faithful (prod sends the real count). Same senderSeat mapping as the cost read.
+ int playedSpellboost = ctx.Engine.PlayedCardSpellboost(senderSeat, playIdx, fallback: 0);
+
var played = KnownListBuilder.BuildPlayedCard(
- deckMap, playIdx, orderList, ctx.State.GetSpellboostMap(ctx.From), cost: playedCost);
- ctx.State.RecordSpellboostFrom(ctx.From, ctx.Other, orderList);
+ deckMap, playIdx, orderList, cost: playedCost, spellboost: playedSpellboost);
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 86c91c4..e0e1a31 100644
--- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
+++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
@@ -10,58 +10,27 @@ 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). 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). is the
- /// engine-RESOLVED play-time cost (M-HC-3a) the handler reads off the shadow engine and passes in;
- /// it lands on the entry verbatim (a vanilla play naturally resolves to its base cost). attachTarget
- /// stays ""; clan/tribe remain deferred (receiver re-derives from cardId).
+ /// (token idx not in the deck map, or no matching move op). and
+ /// are both ENGINE-SOURCED (M-HC-3a/3b) — the handler reads the played
+ /// card's resolved play-time cost (SessionBattleEngine.PlayedCardCost) and accumulated
+ /// spell-charge count (SessionBattleEngine.PlayedCardSpellboost) off the shadow engine and passes
+ /// them in; both land on the entry verbatim. The wire-derived spellboost bookkeeping is retired —
+ /// the engine owns both cost and count by construction (cost folds the spellboost discount in already;
+ /// the count rides the entry only to stay prod-faithful, prod sends the real count here). Prod's client
+ /// reads cost straight into the card's cost model (NetworkBattleReceiver), so a vanilla play
+ /// resolves to its base cost and count 0. attachTarget stays ""; clan/tribe remain deferred (receiver
+ /// re-derives from cardId).
public static KnownCardEntry? BuildPlayedCard(
IReadOnlyDictionary deckMap, int playIdx, object? orderList,
- IReadOnlyDictionary? spellboostMap = null, int cost = 0)
+ int cost = 0, int spellboost = 0)
{
if (!deckMap.TryGetValue(playIdx, out var cardId)) return null;
var to = ExtractMoveTo(orderList, playIdx);
if (to is null) return null;
- 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: "", Cost: cost);
}
- /// 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