refactor(battlenode): retire spellboost bookkeeping, engine owns cost+spellboost (M-HC-3)

The headless engine accumulates spell-charge for real on the receive path
(each spell play runs the played card's own AddSpellChargeCount) and resolves
the discounted cost by construction, so the wire-derived spellboost-count
bookkeeping is redundant. Engine-source the knownList spellboost COUNT too
(prod-faithful) via a new SessionBattleEngine.PlayedCardSpellboost, using the
same persist-post-play zone search as PlayedCardCost (SpellChargeCount survives
PlayCard; only ctor/ReturnCard zero it).

- Delete IdxToSpellboost/SpellboostMap/GetSpellboostMap/RecordSpellboostFrom
  (BattleSessionState) and MineAlterSpellboosts (KnownListBuilder); token/choice/
  copy identity maps are untouched.
- BuildPlayedCard takes an engine-sourced spellboost int (drops spellboostMap).
- Seed BattleLogManager fusion lists headless (the per-frame filter cleanup
  NREs on null EnemyFusionCard when a fanfare card registers a CalledCreateFilter)
  so real spell-charge grantor plays resolve.
- Add committed real-charge regression tests (no SeedHandCardSpellboostCost seam):
  one grantor play accumulates +1 on the reducer -> cost 5->4, count 1, persisting
  post-play; handler emits cost 4 + spellboost 1 engine-sourced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 21:48:50 -04:00
parent 51419d15cd
commit 0d7136787a
9 changed files with 261 additions and 194 deletions

View File

@@ -10,58 +10,27 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
internal static class KnownListBuilder
{
/// <summary>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). <paramref name="spellboostMap"/> supplies
/// the played card's spellboost COUNT (accumulated from prior <c>alter</c> ops via
/// <see cref="MineAlterSpellboosts"/> / <c>BattleSessionState.RecordSpellboostFrom</c>); absent/unmapped
/// idx → 0. Prod sends the real count here and the client reads it straight into the card's cost model
/// (<c>NetworkBattleReceiver</c> spellboost case), so a wrong value makes the opponent compute the
/// card at full price and silently reject the play in <c>OperateReceiveChecker.IsPlayCard</c>
/// (PP-over → ConductError → NullOperationCollection → no render/echo). <paramref name="cost"/> 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).</summary>
/// (token idx not in the deck map, or no matching move op). <paramref name="cost"/> and
/// <paramref name="spellboost"/> are both ENGINE-SOURCED (M-HC-3a/3b) — the handler reads the played
/// card's resolved play-time cost (<c>SessionBattleEngine.PlayedCardCost</c>) and accumulated
/// spell-charge count (<c>SessionBattleEngine.PlayedCardSpellboost</c>) 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 (<c>NetworkBattleReceiver</c>), so a vanilla play
/// resolves to its base cost and count 0. attachTarget stays ""; clan/tribe remain deferred (receiver
/// re-derives from cardId).</summary>
public static KnownCardEntry? BuildPlayedCard(
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList,
IReadOnlyDictionary<int, int>? 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);
}
/// <summary>Mine spellboost-count changes from a sender's <c>orderList</c> <c>alter</c> ops. For each
/// <c>{alter:{idx:[...], isSelf, spellboost:"&lt;op&gt;&lt;n&gt;"}}</c> op, yields
/// <c>(idx, isSelf, op, amount)</c> for every idx — <c>op</c> ∈ {<c>'a'</c> add, <c>'s'</c> set,
/// <c>'h'</c> half} (mirrors <c>RegisterAlter.ChangeType</c>; the leading letter on the value encodes
/// the operation, the rest is the integer amount). <c>isSelf</c> is the sender's perspective tag,
/// surfaced verbatim so the caller routes into the correct side's map (same rule as
/// <see cref="MineAddOps"/>). Skips alter ops with no <c>spellboost</c> key (an alter can also carry
/// cost/atk/etc.), a non-string or too-short value, an unparseable amount, or a non-list <c>idx</c>
/// (e.g. a private-group string idx). The only form seen in real captures is <c>"a1"</c> (each spell
/// play adds 1 to the listed hand cards); set/half are handled for completeness.</summary>
public static IEnumerable<(int Idx, CardOwner IsSelf, char Op, int Amount)> MineAlterSpellboosts(object? orderList)
{
if (orderList is not IEnumerable<object?> ops) yield break;
foreach (var op in ops)
{
if (op is not IDictionary<string, object?> opDict) continue;
if (!opDict.TryGetValue(WireKeys.Alter, out var alterRaw) || alterRaw is not IDictionary<string, object?> 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<object?> idxList) continue;
foreach (var i in idxList)
yield return ((int)AsLong(i), isSelf, opChar, amount);
}
}
/// <summary>The <c>to</c> place-state of the FIRST <c>move</c> op whose <c>idx</c> list contains
/// <paramref name="playIdx"/> (the played card's own move; later add/alter ops are the deferred
/// token slice), or null if absent. NOTE: the sender-side <c>to</c> is passed through verbatim —