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:
@@ -104,48 +104,6 @@ internal sealed class BattleSessionState
|
||||
RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
|
||||
}
|
||||
|
||||
/// <summary>Per-side idx->spellboost COUNT, accumulated from <c>orderList</c> <c>alter</c> ops via
|
||||
/// <see cref="RecordSpellboostFrom"/>. Separate from <see cref="IdxToCardId"/> because spellboost is a
|
||||
/// mutable counter, not an identity. Surfaced by <c>BuildPlayedCard</c> as the played card's
|
||||
/// <c>knownList.spellboost</c> so the opponent computes its discounted cost (see that method).</summary>
|
||||
public Dictionary<IBattleParticipant, Dictionary<int, int>> IdxToSpellboost { get; } = new();
|
||||
|
||||
private Dictionary<int, int> SpellboostMap(IBattleParticipant side)
|
||||
{
|
||||
if (!IdxToSpellboost.TryGetValue(side, out var map))
|
||||
IdxToSpellboost[side] = map = new Dictionary<int, int>();
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>The side's idx->spellboost map (empty if nothing recorded yet). Read by
|
||||
/// <c>PlayActionsHandler</c> to feed <c>BuildPlayedCard</c>.</summary>
|
||||
public IReadOnlyDictionary<int, int> GetSpellboostMap(IBattleParticipant side) => SpellboostMap(side);
|
||||
|
||||
/// <summary>Apply a frame's spellboost <c>alter</c> ops to the per-side maps. Routed by <c>isSelf</c>
|
||||
/// (the sender's perspective) exactly like <see cref="RecordTokensFrom"/>: <c>isSelf:1</c> → the
|
||||
/// sender's own hand (<paramref name="from"/>); <c>isSelf:0</c> → the opponent's hand
|
||||
/// (<paramref name="other"/>) for the rare cross-side spellboost. Ops: <c>'a'</c> add, <c>'s'</c> set,
|
||||
/// <c>'h'</c> half. Call this AFTER <c>BuildPlayedCard</c> 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.</summary>
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mine + record copy/clone-token identities (<see cref="KnownListBuilder.MineCopyTokens"/>)
|
||||
/// into the correct side's map. A copy's source lives at <c>baseIdx</c> in the actor's own index
|
||||
/// space, so the resolution side == the record side, both selected by the same <c>isSelf</c> routing
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:"<op><n>"}}</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 —
|
||||
|
||||
@@ -28,6 +28,7 @@ using UIWidget = engine::UIWidget;
|
||||
using UISprite = engine::UISprite;
|
||||
using NullDetailPanelControl = engine::NullDetailPanelControl;
|
||||
using DetailPanelControl = engine::DetailPanelControl;
|
||||
using BattleLogManager = engine::Wizard.Battle.UI.BattleLogManager;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Engine;
|
||||
|
||||
@@ -94,6 +95,7 @@ internal sealed class SessionBattleEngine
|
||||
InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays
|
||||
InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer
|
||||
InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs
|
||||
SeedBattleLogManager(); // per-frame filter cleanup reads BattleLogManager fusion lists
|
||||
InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent
|
||||
|
||||
// Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList,
|
||||
@@ -162,6 +164,11 @@ internal sealed class SessionBattleEngine
|
||||
/// a card dealt from the seeded deck.</summary>
|
||||
public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index;
|
||||
|
||||
/// <summary>The real <c>CardId</c> (wire identity) of the hand card at <paramref name="handPos"/>. Lets a
|
||||
/// test locate a specific card in a SHUFFLED opening hand by identity (then read its <see cref="HandCardIndex"/>
|
||||
/// to drive a play), without depending on which shuffled position the card landed at.</summary>
|
||||
public int HandCardId(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].CardId;
|
||||
|
||||
/// <summary>The real <c>CardId</c> (wire identity) of the in-play follower at <paramref name="boardPos"/>
|
||||
/// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as
|
||||
/// <see cref="BoardCount"/>). Used to assert an opponent reveal seated the substituted card with its
|
||||
@@ -196,6 +203,28 @@ internal sealed class SessionBattleEngine
|
||||
return card.PlayedCost >= 0 ? card.PlayedCost : card.Cost;
|
||||
}
|
||||
|
||||
/// <summary>The engine-RESOLVED spellboost (spell-charge) COUNT of the card whose engine <c>Index</c> ==
|
||||
/// <paramref name="idx"/> on <paramref name="playerSeat"/> (M-HC-3b). The engine accumulates this count
|
||||
/// for real on the receive path (each spell play that targets the card runs the card's own
|
||||
/// <c>Skill_spell_charge.AddSpellChargeCount</c>), so this is the same authoritative count prod sends —
|
||||
/// emitted on the opponent-facing knownList so the wire stays prod-faithful now that the wire-derived
|
||||
/// spellboost bookkeeping is retired (cost itself is engine-sourced via <see cref="PlayedCardCost"/>).
|
||||
/// <para>READ-MOMENT (persist-post-play): <see cref="BattleCardBase.SpellChargeCount"/> is set to 0 only
|
||||
/// in the ctor (re-init, BattleCardBase.cs:2042) and in <c>ReturnCard</c> (bounce-to-hand,
|
||||
/// BattleCardBase.cs:2681); <see cref="BattleCardBase.PlayCard"/> never touches it. So the count PERSISTS
|
||||
/// on the played card object after it leaves the hand (follower in-play, spell in cemetery) — the same
|
||||
/// persist-after-play property <see cref="BattleCardBase.PlayedCost"/> has. We therefore use the SAME
|
||||
/// post-resolution zone search (<see cref="FindByIndex"/>: in-play → cemetery → hand) and read
|
||||
/// <c>SpellChargeCount</c> directly — no separate receive-capture is needed.</para>
|
||||
/// <para>Degrades to <paramref name="fallback"/> when the engine is not set up or the idx resolves to no
|
||||
/// card — so a non-engine session never crashes and a vanilla play emits 0 via the caller's fallback.</para></summary>
|
||||
public int PlayedCardSpellboost(bool playerSeat, int idx, int fallback = 0)
|
||||
{
|
||||
if (_mgr is null) return fallback;
|
||||
var card = FindByIndex(Seat(playerSeat), idx);
|
||||
return card?.SpellChargeCount ?? fallback;
|
||||
}
|
||||
|
||||
// Locate the card with the given engine Index across the seat's post-resolution zones. Order matters
|
||||
// only for disambiguation; Index is unique per card so the first hit is the card. In-play (followers)
|
||||
// and cemetery (spells) are where a just-resolved play lands; hand is the pre-resolution fallback.
|
||||
@@ -399,6 +428,19 @@ internal sealed class SessionBattleEngine
|
||||
return card;
|
||||
}
|
||||
|
||||
// The per-frame skill-filter cleanup (BattleManagerBase.RemoveUnUseCalledFilterDictionary, run on
|
||||
// EVERY receive) reads BattleLogManager.GetInstance().EnemyFusionCard.Contains(...) when a card with a
|
||||
// registered CalledCreateFilter is alive — e.g. a follower with a when_play spell_charge/fanfare skill
|
||||
// (BattleManagerBase.cs:155). The shim BattleLogManager singleton leaves PlayerFusionCard/EnemyFusionCard
|
||||
// null (no UI ran SetUp), so that .Contains NREs. Seed both to empty lists — a pure no-op view-state
|
||||
// seed (the fusion log is cosmetic; nothing headless adds to it). Process-global like the other seeds.
|
||||
private static void SeedBattleLogManager()
|
||||
{
|
||||
var log = BattleLogManager.GetInstance();
|
||||
log.PlayerFusionCard ??= new List<BattleCardBase>();
|
||||
log.EnemyFusionCard ??= new List<BattleCardBase>();
|
||||
}
|
||||
|
||||
// The turn-flow + emit bookkeeping reads the global ToolboxGame.RealTimeNetworkAgent (e.g.
|
||||
// RealTimeNetworkAgent.GetIsFirstPlayer/GetTurnState, which delegate to GameMgr's
|
||||
// NetworkUserInfoData.TurnState; AddActionSequence touches _gungnir). Headless there is no socket
|
||||
|
||||
Reference in New Issue
Block a user