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:
@@ -526,4 +526,174 @@ public class HeadlessConductorTests
|
||||
Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(SpellboostReducerBaseCost),
|
||||
"non-vacuity: the emitted cost must NOT be the un-discounted base cost");
|
||||
}
|
||||
|
||||
// === M-HC-3b: REAL spell-charge accumulation (no seam) =======================================
|
||||
|
||||
// The spellboost GRANTOR 118311030: a cost-3 follower whose when_play spell_charge skill
|
||||
// (add_charge=1, target character=me&target=hand&card_type=all) adds +1 spell-charge to EVERY card in
|
||||
// the caster's hand on each play. Drives the reducer's charge for real headless — no SeedHandCardSpellboostCost
|
||||
// seam. (Its authored SECOND charge skill, add_charge=5, does NOT fire headless — only +1 lands per play;
|
||||
// recorded as a known fidelity follow-up, irrelevant to this regression which needs only the +1.)
|
||||
private const long SpellboostGrantorId = 118311030;
|
||||
|
||||
// A deck of alternating reducers + grantors so both reliably populate the opening hand and early draws
|
||||
// (a single front-loaded reducer would shuffle out of reach). 15 of each = 30.
|
||||
private static IReadOnlyList<long> ReducerAndGrantorDeck()
|
||||
{
|
||||
var deck = new List<long>(30);
|
||||
for (int i = 0; i < 15; i++) { deck.Add(SpellboostReducerId); deck.Add(SpellboostGrantorId); }
|
||||
return deck;
|
||||
}
|
||||
|
||||
// Find the engine Index of the first hand card on seat A with the given wire cardId (the hand is
|
||||
// shuffled, so we locate by identity, not position). -1 if not present.
|
||||
private static int FindHandIdxByCardId(NodeNativeBattleHarness harness, long cardId)
|
||||
{
|
||||
for (int i = 0; i < harness.HandCount(playerSeat: true); i++)
|
||||
if (harness.HandCardId(playerSeat: true, i) == (int)cardId)
|
||||
return harness.HandCardIndex(playerSeat: true, i);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Ramp seat A to its turn `targetTurn` by alternating TurnStart/TurnEnd A/B; leaves seat A's turn OPEN.
|
||||
private void RampToSeatATurn(NodeNativeBattleHarness harness, int targetTurn)
|
||||
{
|
||||
bool seatA = true;
|
||||
while (true)
|
||||
{
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: seatA).Accepted,
|
||||
Is.True, "TurnStart");
|
||||
if (seatA && harness.Turn(playerSeat: true) == targetTurn) return;
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: seatA).Accepted,
|
||||
Is.True, "TurnEnd");
|
||||
seatA = !seatA;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Real_spell_charge_drops_engine_cost_and_count_no_seam()
|
||||
{
|
||||
// The committed M-HC-3b closure guard: drive a REAL spell-charge sequence headless (NO
|
||||
// SeedHandCardSpellboostCost seam) and assert the engine-sourced COST and SPELLBOOST COUNT the node
|
||||
// now emits are both correct by construction. Proves the retired wire-derived bookkeeping is
|
||||
// redundant: the engine accumulates the charge itself (each grantor play runs the reducer's own
|
||||
// AddSpellChargeCount) and resolves the discount.
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerAndGrantorDeck());
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
|
||||
// Ramp to seat A turn 3 (PP 3) so the cost-3 grantor is affordable.
|
||||
RampToSeatATurn(harness, targetTurn: 3);
|
||||
Assert.That(harness.Pp(playerSeat: true), Is.EqualTo(3), "seat A PP at turn 3");
|
||||
|
||||
// Locate a reducer + a grantor in the (shuffled) hand by identity.
|
||||
int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId);
|
||||
int grantorIdx = FindHandIdxByCardId(harness, SpellboostGrantorId);
|
||||
Assert.That(reducerIdx, Is.GreaterThan(0), "a reducer must be in seat A's opening hand");
|
||||
Assert.That(grantorIdx, Is.GreaterThan(0), "a grantor must be in seat A's opening hand");
|
||||
|
||||
// PRE-CHARGE non-vacuity: the reducer resolves to its BASE cost (5) and 0 charge BEFORE any grant.
|
||||
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1),
|
||||
Is.EqualTo(SpellboostReducerBaseCost), "reducer cost is base (5) before any charge");
|
||||
Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1),
|
||||
Is.EqualTo(0), "reducer spell-charge is 0 before any grant");
|
||||
|
||||
// Play the grantor (cost 3). Its when_play spell_charge adds +1 to every hand card — REAL engine
|
||||
// resolution, no seam. This runs through the receive conductor (Push -> engine.Receive).
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(grantorIdx), isPlayerSeat: true).Accepted,
|
||||
Is.True, "grantor play");
|
||||
|
||||
// THE engine-read assertions: the reducer (still in hand) now reads charge 1 and cost 4 (5 - 1) —
|
||||
// accumulated for real by the engine, not seeded.
|
||||
Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1),
|
||||
Is.EqualTo(1), "one grantor play accumulates +1 real spell-charge on the reducer");
|
||||
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1),
|
||||
Is.EqualTo(SpellboostReducerBaseCost - 1),
|
||||
"the engine resolves the reducer's cost down to 4 (base 5 - 1 charge), no seam");
|
||||
|
||||
// PERSIST-POST-PLAY proof (the read-moment this milestone chose): advance to seat A's next turn
|
||||
// (fresh PP 4, affording the cost-4 reducer), play the reducer (a spell -> cemetery), and confirm
|
||||
// PlayedCardSpellboost/PlayedCardCost STILL read 1/4 AFTER the card left the hand — i.e. the zone
|
||||
// search reads the persisted count off the resolved card, no receive-capture needed.
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True);
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True);
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True);
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True);
|
||||
Assert.That(harness.Pp(playerSeat: true), Is.GreaterThanOrEqualTo(4), "seat A fresh PP affords cost-4 reducer");
|
||||
|
||||
// The reducer's engine Index is stable across turns; play it now.
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(reducerIdx), isPlayerSeat: true).Accepted,
|
||||
Is.True, "charged reducer play");
|
||||
Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1),
|
||||
Is.EqualTo(1), "spell-charge persists on the played reducer (now in cemetery)");
|
||||
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1),
|
||||
Is.EqualTo(SpellboostReducerBaseCost - 1),
|
||||
"PlayedCost captured the discounted cost (4) at play time and persists post-play");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handler_emits_real_engine_spellboost_and_cost_on_knownList()
|
||||
{
|
||||
// The end-to-end emit payoff for M-HC-3b: a REAL-charged reducer played through the conductor, then
|
||||
// PlayActionsHandler.Handle, with BOTH knownList[].cost AND knownList[].spellboost read straight off
|
||||
// the engine (no wire-derived bookkeeping). Cost 4 (discounted) + count 1 (real charge).
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerAndGrantorDeck());
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
RampToSeatATurn(harness, targetTurn: 3);
|
||||
|
||||
int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId);
|
||||
int grantorIdx = FindHandIdxByCardId(harness, SpellboostGrantorId);
|
||||
Assert.That(reducerIdx, Is.GreaterThan(0), "reducer in hand");
|
||||
Assert.That(grantorIdx, Is.GreaterThan(0), "grantor in hand");
|
||||
|
||||
// Charge the reducer for real (one grantor play -> +1), then advance to a fresh seat A turn that
|
||||
// affords the discounted reducer.
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(grantorIdx), isPlayerSeat: true).Accepted,
|
||||
Is.True, "grantor play");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True);
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True);
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True);
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True);
|
||||
|
||||
// Ingest the reducer play into the engine (so PlayedCost/SpellChargeCount are captured at resolution).
|
||||
var playBody = HandlerPlayBody(reducerIdx);
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, playBody, isPlayerSeat: true).Accepted,
|
||||
Is.True, "charged reducer play ingest");
|
||||
|
||||
// Build the dispatch context the way BattleSession.BuildContext does; From == seat A (the sender).
|
||||
harness.SeatA.Phase = HandshakePhase.AfterReady;
|
||||
harness.SeatB.Phase = HandshakePhase.AfterReady;
|
||||
var env = new MsgEnvelope(
|
||||
NetworkBattleUri.PlayActions, ViewerId: harness.SeatA.ViewerId, Uuid: "udid-test", Bid: null,
|
||||
RetryAttempt: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new RawBody(playBody));
|
||||
var ctx = new FrameDispatchContext
|
||||
{
|
||||
A = harness.SeatA, B = harness.SeatB, From = harness.SeatA, Other = harness.SeatB,
|
||||
Env = env, BattleId = "test-battle", State = harness.State, Engine = harness.Engine,
|
||||
};
|
||||
|
||||
var routes = new PlayActionsHandler().Handle(ctx);
|
||||
|
||||
Assert.That(routes, Has.Count.EqualTo(1), "one route to the opponent");
|
||||
var body = routes[0].Frame.Body as PlayActionsBroadcastBody;
|
||||
Assert.That(body, Is.Not.Null, "frame body is a PlayActionsBroadcastBody");
|
||||
Assert.That(body!.KnownList, Is.Not.Null.And.Count.EqualTo(1), "one knownList entry (the played reducer)");
|
||||
Assert.That(body.KnownList![0].CardId, Is.EqualTo(SpellboostReducerId), "the reducer's identity");
|
||||
// THE assertions: cost is the engine-resolved DISCOUNTED cost (4), spellboost is the REAL count (1).
|
||||
Assert.That(body.KnownList[0].Cost, Is.EqualTo(SpellboostReducerBaseCost - 1),
|
||||
"knownList[].cost must be the engine-resolved discounted cost (4), not base (5)");
|
||||
Assert.That(body.KnownList[0].Spellboost, Is.EqualTo(1),
|
||||
"knownList[].spellboost must be the REAL engine-accumulated charge count (1), engine-sourced");
|
||||
// Non-vacuity: neither field is the un-charged default.
|
||||
Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(SpellboostReducerBaseCost),
|
||||
"non-vacuity: emitted cost is NOT the un-discounted base cost");
|
||||
Assert.That(body.KnownList[0].Spellboost, Is.Not.EqualTo(0),
|
||||
"non-vacuity: emitted spellboost is NOT 0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,13 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
/// Play frame would carry to play it).</summary>
|
||||
public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos);
|
||||
|
||||
/// <summary>The wire CardId of the hand card at <paramref name="handPos"/> on the given seat. Lets a
|
||||
/// test find a specific card (e.g. the spellboost reducer) in a shuffled opening hand by identity.</summary>
|
||||
public int HandCardId(bool playerSeat, int handPos) => Engine.HandCardId(playerSeat, handPos);
|
||||
|
||||
/// <summary>The engine Index of the hand card at <paramref name="handPos"/> on the given seat.</summary>
|
||||
public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos);
|
||||
|
||||
/// <summary>The real wire <c>CardId</c> of the in-play follower at <paramref name="boardPos"/> on the
|
||||
/// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity
|
||||
/// (M-HC-2).</summary>
|
||||
|
||||
@@ -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