feat(battlenode): emit engine-resolved cost on every knownList entry (M-HC-3)

The opponent-facing PlayActions knownList now carries the engine-RESOLVED
play-time cost (KnownCardEntry.cost), sourced from the headless shadow engine's
PlayedCost on the just-resolved card. This closes the spellboost cost-desync BY
CONSTRUCTION: the engine already knows the true discounted cost (spellboost +
board modifiers folded in), so no bookkeeping is needed.

- DTO: add non-nullable cost to KnownCardEntry (prod emits cost 45/45).
- SessionBattleEngine.PlayedCardCost(seat, idx, fallback): finds the resolved
  card by engine Index across in-play/cemetery/hand zones and returns PlayedCost
  (captured by PlayCard at resolution == discounted Cost), degrading to fallback
  when the engine is not owned/ready.
- PlayActionsHandler sources the played card's cost from ctx.Engine (ShadowIngest
  already resolved the play before the handler runs). Spellboost-map plumbing
  stays for now; Task 6 (M-HC-3b) retires it.
- Validation: engine-read test (charge-seeded reducer 101314020: base 5, cost
  5/1/0 at charge 0/4/5) + handler-emit test asserting knownList[0].cost == 1
  (discounted, not base 5) with non-vacuity. Board-dependent (when_evolve_other)
  case deferred to M-HC-4 (evolve not yet headless); cost is read off the resolved
  engine so board modifiers are captured by construction once their ops resolve.
- Harness: promote alt vanilla follower id (101211120) to AltVanillaFollowerId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 21:18:29 -04:00
parent b73f0f7157
commit 51419d15cd
7 changed files with 299 additions and 15 deletions

View File

@@ -1,6 +1,11 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Sessions.Dispatch;
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
namespace SVSim.UnitTests.BattleNode.Integration;
@@ -23,6 +28,15 @@ namespace SVSim.UnitTests.BattleNode.Integration;
/// <c>TurnEnd</c>) resolve headless, so two full turns of a node-native battle track on engine
/// state (hand/board/PP/deck/turn/leader-life on both seats match the deterministic progression
/// at each boundary). All driven through the same receive conductor.
///
/// Task 5 (M-HC-3a) exit criterion: the opponent-facing <c>knownList[].cost</c> carries the
/// engine-RESOLVED play-time cost (the discounted cost the engine actually charged), closing the
/// spellboost cost-desync by construction. Proven both at the engine read (PlayedCardCost off a
/// charge-seeded reducer) and the handler emit (PlayActionsHandler -> PlayActionsBroadcastBody).
/// NOTE: a BOARD-DEPENDENT cost reducer (e.g. <c>when_evolve_other</c>) is DEFERRED to M-HC-4 —
/// evolve does not yet resolve headless. Because cost is read straight off the resolved engine,
/// board modifiers are captured by construction once their ops resolve, so no separate emit-site
/// change is needed when M-HC-4 lands; only a board-dependent validation case is owed there.
/// </summary>
[TestFixture]
[NonParallelizable]
@@ -266,7 +280,7 @@ public class HeadlessConductorTests
// seats at seat B's first-turn PP (1). The id is NOT in seat B's deck, so the only way it
// can appear on the board is the reveal substituting it in.
const long Z = NodeNativeBattleHarness.VanillaFollowerId; // 100011010
const long W = 101211120;
const long W = NodeNativeBattleHarness.AltVanillaFollowerId; // 101211120
Assert.That(W, Is.Not.EqualTo(Z), "Z and W must differ for the substitution to be observable");
// Uniform Z deck for seat B (every dummy is Z regardless of shuffle). Seat A left at default.
@@ -350,4 +364,166 @@ public class HeadlessConductorTests
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
"PP must drop by the played card's cost");
}
// === M-HC-3a: engine-resolved cost on the knownList ==========================================
// The spellboost cost-reducer 101314020 (base cost 5). Its when_spell_charge cost_change skill
// (skill_option add=ADD_CHARGE_COUNT*-1) reduces its OWN cost by 1 per accumulated spellboost
// charge — so resolved cost == max(0, 5 - charge). The harness seeds the charge directly
// (SeedHandCardSpellboostCost registers the same CostAddModifier(-1)/charge the engine's own
// Skill_cost_change builds) because pumping real charge needs the VFX-coupled spell-charge chain.
private const long SpellboostReducerId = NodeNativeBattleHarness.SpellboostCardId; // 101314020
private const int SpellboostReducerBaseCost = 5;
// A deck made UNIFORMLY of the spellboost reducer, so whatever idx the shuffle parks at engine
// Index 1 (the first dealt card) is unambiguously the reducer — no need to chase the shuffled
// position. (A non-uniform deck would shuffle the reducer off idx 1; the cost read would then be a
// vanilla's base 1, masking the discount — that is exactly the first RED this surfaced.)
private static IReadOnlyList<long> ReducerDeck() => Enumerable.Repeat(SpellboostReducerId, 30).ToList();
[TestCase(0, SpellboostReducerBaseCost)] // no charge -> base cost (5)
[TestCase(4, 1)] // 4 charges -> 5 - 4 = 1
[TestCase(5, 0)] // 5 charges -> max(0, 5 - 5) = 0
public void PlayedCardCost_reads_engine_resolved_discounted_cost(int charge, int expectedCost)
{
// ENGINE-READ proof (the count->cost resolution off the real Cost getter). Drive a node-native
// battle to seat A's turn 1, seed the reducer's spellboost charge, play it, and read the cost the
// engine actually charged. expectedCost is base(5) - charge, the engine's authentic resolution —
// and the differing values across charge levels are the non-vacuity (a wrong charge -> wrong cost).
using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerDeck());
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");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
// The reducer dealt at engine Index 1 (deck position 0). Seed the charge on it WHILE it is in hand,
// then confirm the engine's Cost getter resolved the discount BEFORE the play (pre-play pin).
int seededHandCost = harness.Engine.SeedHandCardSpellboostCost(playerSeat: true, idx: 1, charge);
Assert.That(seededHandCost, Is.EqualTo(expectedCost),
$"engine hand-card Cost must resolve base({SpellboostReducerBaseCost}) - charge({charge})");
// Play it. With max(0,5-charge) <= 1 for charge 4/5, and charge 0 keeping cost 5 (PP 1 can't pay
// 5), we only need the cost READ to be correct — but assert acceptance where affordable.
var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true);
if (expectedCost <= 1)
Assert.That(play.Accepted, Is.True, $"affordable reducer play rejected: {play.RejectReason}");
// The PAYOFF read: PlayedCardCost returns the engine-resolved play-time cost. For an affordable
// play this is the captured PlayedCost (post-resolution, card now in cemetery — it is a spell);
// for the unaffordable charge-0 case the card stays in hand and the live Cost (5) is read. Either
// way the value equals the engine's resolved discounted cost.
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, idx: 1),
Is.EqualTo(expectedCost),
$"PlayedCardCost must equal the engine-resolved cost {expectedCost} at charge {charge}");
}
[Test]
public void Vanilla_play_PlayedCardCost_is_base_cost()
{
// A vanilla follower has no cost modifier, so the engine resolves its base cost (1) by
// construction — the cost the knownList will carry for a non-boosted play.
var deck = new List<long> { NodeNativeBattleHarness.VanillaFollowerId };
deck.AddRange(NodeNativeBattleHarness.DefaultDeck());
deck = deck.GetRange(0, 30);
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck);
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True);
var playIdx = harness.PlayerHandCardIndex(0);
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(playIdx), isPlayerSeat: true).Accepted,
Is.True, "vanilla play");
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, idx: playIdx), Is.EqualTo(1),
"a cost-1 vanilla follower resolves to base cost 1");
}
[Test]
public void PlayedCardCost_degrades_to_fallback_for_unknown_idx()
{
// Graceful degradation: an idx with no resolved card returns the fallback (non-engine sessions
// and unmapped idxs never crash the handler).
using var harness = NodeNativeBattleHarness.Create();
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, idx: 9999, fallback: 7), Is.EqualTo(7));
}
// --- HANDLER-EMIT proof: the cost reaches the opponent-facing knownList[].cost ----------------
// A PlayActions wire frame the HANDLER consumes: it needs an orderList move op for the played idx so
// BuildPlayedCard can synthesize the entry (the engine resolves the play from playIdx/type alone, but
// the opponent-facing synthesis is driven by the wire orderList). to:30 == Field.
private static Dictionary<string, object?> HandlerPlayBody(int playIdx) => new()
{
["playIdx"] = playIdx,
["type"] = 30,
["orderList"] = new List<object?>
{
new Dictionary<string, object?>
{
["move"] = new Dictionary<string, object?>
{
["idx"] = new List<object?> { (long)playIdx },
["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L,
},
},
},
};
[Test]
public void Handler_emits_engine_resolved_cost_on_knownList()
{
// The end-to-end payoff: build a FrameDispatchContext over the harness (engine + state +
// participants), drive to seat A's turn, seed the reducer's charge, INGEST the play (so the engine
// resolves + captures PlayedCost), then run PlayActionsHandler.Handle and inspect the emitted
// knownList[0].cost. It must equal the engine-resolved discounted cost (NOT the base cost) —
// proving the cost-desync is closed by construction at the emit site.
const int charge = 4;
const int expectedCost = SpellboostReducerBaseCost - charge; // 1
using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerDeck());
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");
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
Is.True, "turn1 TurnStart");
// Seed the charge so the engine resolves the reducer at cost 1 (affordable on PP 1).
Assert.That(harness.Engine.SeedHandCardSpellboostCost(playerSeat: true, idx: 1, charge),
Is.EqualTo(expectedCost), "pre-play resolved hand cost");
// Ingest the play into the engine (seat A == player) so PlayedCost is captured at resolution.
var playBody = HandlerPlayBody(1);
Assert.That(harness.Push(NetworkBattleUri.PlayActions, playBody, isPlayerSeat: true).Accepted,
Is.True, "reducer play ingest");
// Build the dispatch context the way BattleSession.BuildContext does, with both stubs advanced to
// AfterReady so the PvP relay gate (BothSidesAfterReady) passes. 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 card)");
Assert.That(body.KnownList![0].CardId, Is.EqualTo(SpellboostReducerId), "the reducer's identity");
// THE assertion: the emitted cost is the engine-resolved DISCOUNTED cost (1), not the base (5).
Assert.That(body.KnownList[0].Cost, Is.EqualTo(expectedCost),
"knownList[].cost must be the engine-resolved discounted cost, not the base cost");
Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(SpellboostReducerBaseCost),
"non-vacuity: the emitted cost must NOT be the un-discounted base cost");
}
}

View File

@@ -3,6 +3,7 @@ using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Sessions.Dispatch;
using SVSim.BattleNode.Sessions.Engine;
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.UnitTests.BattleNode.Integration;
@@ -55,6 +56,13 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// it will produce a traceable failure here.</summary>
public const long VanillaFollowerId = 100011010;
/// <summary>A SECOND, distinct cost-1 vanilla follower (char_type 1, cost 1, no skill) — present +
/// creatable in cards.json. Used by the opponent-reveal substitution test as the WIRE cardId that
/// must override a seeded identity (it is deliberately NOT in any harness deck, so its only route
/// onto the board is a reveal). Named here so card-id provenance stays traceable as ids accumulate
/// (Task-4 review nit promoted in M-HC-3).</summary>
public const long AltVanillaFollowerId = 101211120;
public BattleSessionState State { get; }
public StubParticipant SeatA { get; }
public StubParticipant SeatB { get; }
@@ -156,11 +164,18 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// (<c>PushAsync</c>, <c>RunAsync</c>, <c>TerminateAsync</c>) throw <see cref="NotSupportedException"/>
/// — the harness drives the engine directly, so a frame must never reach the participant relay.
/// Silent no-ops would let a misrouted push pass undetected.</summary>
internal sealed class StubParticipant : IBattleParticipant
internal sealed class StubParticipant : IBattleParticipant, IHasHandshakePhase
{
public long ViewerId { get; }
public MatchContext Context { get; }
/// <summary>Handshake cursor (M-HC-3a handler-emit test). Implementing
/// <see cref="IHasHandshakePhase"/> lets a test build a <c>FrameDispatchContext</c> over two
/// StubParticipants and advance both to <see cref="HandshakePhase.AfterReady"/> so
/// <c>BothSidesAfterReady()</c> passes (the PvP relay gate). Harness tests that drive the engine
/// directly never read this; it defaults to the pre-handshake state and is harmless to them.</summary>
public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
public StubParticipant(long viewerId, MatchContext context)
{
ViewerId = viewerId;