fix(battlenode): emit real spellboost count in played-card knownList
The node hardcoded knownList.spellboost=0 on every played card. Prod sends the true accumulated count, which the client reads straight into the card's cost model; with 0 the opponent computes the card at full price and silently rejects the play in OperateReceiveChecker.IsPlayCard (PP-over -> ConductError -> NullOperationCollection -> no render/echo), desyncing the board. Mine spellboost-count changes from the sender''s orderList alter ops (MineAlterSpellboosts: a/s/h ops), accumulate per-side idx->count in BattleSessionState (RecordSpellboostFrom), and surface the current count on the played card via BuildPlayedCard. Recorded from the authoritative PlayActions only (never the Echo) and folded in AFTER the played card is built, since a card''s cost is fixed as it leaves hand and a play that grants spellboost targets the rest of the hand. Also adds a [sio-in-body] full-body inbound log to RealParticipant to capture both clients'' re-simulated responses for PvP RNG verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,49 @@ 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();
|
||||
}
|
||||
|
||||
@@ -92,6 +92,69 @@ 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()
|
||||
{
|
||||
// The fix: a boosted card's knownList must carry its real count (prod sends 1/2/3), not 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