Files
SVSimServer/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs
gamer147 13f902ce58 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>
2026-06-05 13:51:40 -04:00

137 lines
6.3 KiB
C#

using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Sessions.Dispatch;
namespace SVSim.UnitTests.BattleNode.Sessions;
[TestFixture]
public class BattleSessionStateTests
{
private sealed class StubParticipant : IBattleParticipant
{
public long ViewerId { get; }
public MatchContext Context { get; }
public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public StubParticipant(long id, MatchContext ctx) { ViewerId = id; Context = ctx; }
public Task PushAsync(SVSim.BattleNode.Protocol.MsgEnvelope e, Stock n, CancellationToken c) => Task.CompletedTask;
public Task RunAsync(CancellationToken c) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private void Touch() => FrameEmitted?.Invoke(null!, default);
}
private static MatchContext Ctx(params long[] deck) => new(
SelfDeckCardIds: deck, ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "cm",
CountryCode: CountryCodes.Korea, UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0",
FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
[Test]
public void GetOrSeedDeckMap_maps_idx_1based_to_the_shuffled_order()
{
// The map seeds from GetShuffledDeck, not raw build order. idx (i+1) -> shuffledDeck[i],
// and the set of cardIds is unchanged (1..3 present, 4 absent).
var state = new BattleSessionState(masterSeed: 12345);
var p = new StubParticipant(1, Ctx(900L, 901L, 902L));
var shuffled = state.GetShuffledDeck(p);
var map = state.GetOrSeedDeckMap(p);
Assert.That(map[1], Is.EqualTo(shuffled[0]));
Assert.That(map[2], Is.EqualTo(shuffled[1]));
Assert.That(map[3], Is.EqualTo(shuffled[2]));
Assert.That(map.ContainsKey(4), Is.False);
Assert.That(new[] { map[1], map[2], map[3] }, Is.EquivalentTo(new[] { 900L, 901L, 902L }));
}
[Test]
public void GetOrSeedDeckMap_is_idempotent_same_instance()
{
var state = new BattleSessionState();
var p = new StubParticipant(1, Ctx(900L));
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p)));
}
[Test]
public void GetShuffledDeck_is_a_permutation_of_the_input()
{
var state = new BattleSessionState(masterSeed: 12345);
var p = new StubParticipant(1001, Ctx(DistinctDeck()));
Assert.That(state.GetShuffledDeck(p), Is.EquivalentTo(DistinctDeck()),
"same multiset of cards, just reordered");
}
[Test]
public void GetShuffledDeck_actually_reorders_a_distinct_deck()
{
var state = new BattleSessionState(masterSeed: 12345);
var p = new StubParticipant(1001, Ctx(DistinctDeck()));
Assert.That(state.GetShuffledDeck(p), Is.Not.EqualTo(DistinctDeck()),
"a 30-card distinct deck should not survive the shuffle in original order");
}
[Test]
public void GetShuffledDeck_is_deterministic_for_same_master_seed_and_viewer()
{
var a = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
var b = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
Assert.That(a, Is.EqualTo(b));
}
[Test]
public void GetShuffledDeck_differs_across_master_seeds()
{
var a = new BattleSessionState(masterSeed: 1).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
var b = new BattleSessionState(masterSeed: 2).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
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();
}