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

@@ -91,11 +91,23 @@ public class KnownListBuilderTests
// M-HC-3a: the handler reads the engine-resolved play-time cost and passes it in; BuildPlayedCard
// lands it on the entry verbatim. (A wrong cost yields a different field — non-vacuity.)
var deckMap = new Dictionary<int, long> { [3] = 101314020L };
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), spellboostMap: null, cost: 3);
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), cost: 3);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Cost, Is.EqualTo(3));
}
[Test]
public void BuildPlayedCard_emits_engine_sourced_spellboost_count()
{
// M-HC-3b: the handler reads the engine-resolved spell-charge count
// (SessionBattleEngine.PlayedCardSpellboost) and passes it in; BuildPlayedCard lands it on the
// entry verbatim. (Default 0 vs a non-zero value is the non-vacuity.)
var deckMap = new Dictionary<int, long> { [3] = 101314020L };
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), cost: 3, spellboost: 2);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Spellboost, Is.EqualTo(2));
}
[Test]
public void BuildPlayedCard_returns_null_for_token_idx_not_in_deck()
{
@@ -104,69 +116,15 @@ 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<object?> AlterSpellboostOp(long[] idxs, string value, long isSelf = 1) => new()
{
new Dictionary<string, object?>
{
["alter"] = new Dictionary<string, object?>
{
["idx"] = idxs.Select(i => (object?)i).ToList(),
["isSelf"] = isSelf, ["type"] = "add", ["spellboost"] = value,
}
}
};
[Test]
public void BuildPlayedCard_emits_spellboost_count_from_map()
public void BuildPlayedCard_defaults_spellboost_to_zero_when_caller_passes_none()
{
// The fix: a boosted card's knownList must carry its real count (prod sends 1/2/3), not 0.
// A vanilla play emits spellboost 0 (the engine resolves no spell-charge for a non-boosted card,
// so the handler's PlayedCardSpellboost read is 0 and the param defaults to 0).
var deckMap = new Dictionary<int, long> { [3] = 101311010L };
var spellboost = new Dictionary<int, int> { [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<int, long> { [3] = 101311010L };
var otherIdx = new Dictionary<int, int> { [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<object?>
{
new Dictionary<string, object?> { ["alter"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 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()
{