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:
@@ -88,49 +88,6 @@ public class BattleSessionStateTests
|
||||
Assert.That(a, Is.Not.EqualTo(b));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RecordSpellboostFrom_accumulates_add_ops_and_routes_by_isSelf()
|
||||
{
|
||||
var state = new BattleSessionState(masterSeed: 1);
|
||||
var caster = new StubParticipant(7, Ctx(900L));
|
||||
var oppo = new StubParticipant(6, Ctx(901L));
|
||||
|
||||
// Two spell-plays each grant +1 to the caster's hand card idx 3 (the classic spellboost ramp).
|
||||
var grant = new List<object?> { new Dictionary<string, object?>
|
||||
{ ["alter"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["type"] = "add", ["spellboost"] = "a1" } } };
|
||||
state.RecordSpellboostFrom(caster, oppo, grant);
|
||||
state.RecordSpellboostFrom(caster, oppo, grant);
|
||||
|
||||
Assert.That(state.GetSpellboostMap(caster)[3], Is.EqualTo(2), "two +1 grants accumulate");
|
||||
Assert.That(state.GetSpellboostMap(oppo).ContainsKey(3), Is.False, "isSelf:1 routes to the caster only");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Boosted_card_carries_real_spellboost_into_its_knownList()
|
||||
{
|
||||
// End-to-end regression for the 2026-06-05 desync: a spellboosted card whose relayed knownList
|
||||
// shipped spellboost:0 made the opponent compute full cost and silently reject the play. After the
|
||||
// grant is recorded, BuildPlayedCard must emit the accumulated count, not 0.
|
||||
var state = new BattleSessionState(masterSeed: 1);
|
||||
var caster = new StubParticipant(7, Ctx(101311010L, 999L, 998L)); // idx 1..3 deck-seeded
|
||||
|
||||
var grant = new List<object?> { new Dictionary<string, object?>
|
||||
{ ["alter"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["type"] = "add", ["spellboost"] = "a1" } } };
|
||||
state.RecordSpellboostFrom(caster, new StubParticipant(6, Ctx(901L)), grant);
|
||||
|
||||
// idx 3 is now played hand->board; its knownList must reflect the +1.
|
||||
var play = new List<object?> { new Dictionary<string, object?>
|
||||
{ ["move"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L } } };
|
||||
var entry = KnownListBuilder.BuildPlayedCard(
|
||||
state.GetOrSeedDeckMap(caster), playIdx: 3, orderList: play, state.GetSpellboostMap(caster));
|
||||
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
Assert.That(entry!.Spellboost, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
private static long[] DistinctDeck() =>
|
||||
Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToArray();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user