From 51419d15cd06c33b8814172c20aa0844e541cf8a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 21:18:29 -0400 Subject: [PATCH] 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 --- .../Bodies/PlayActionsBroadcastBody.cs | 12 +- .../Dispatch/Handlers/PlayActionsHandler.cs | 20 +- .../Sessions/Dispatch/KnownListBuilder.cs | 11 +- .../Sessions/Engine/SessionBattleEngine.cs | 64 +++++++ .../Integration/HeadlessConductorTests.cs | 178 +++++++++++++++++- .../Integration/NodeNativeBattleHarness.cs | 17 +- .../Sessions/KnownListBuilderTests.cs | 12 ++ 7 files changed, 299 insertions(+), 15 deletions(-) diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs index ac3463e..0aef775 100644 --- a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs +++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs @@ -36,15 +36,19 @@ public sealed record SelectCardEntry( [property: JsonPropertyName("open")] [property: JsonConverter(typeof(JsonNumberEnumConverter))] ChoiceVisibility Open); -/// One revealed card in a knownList. Vanilla slice fills cardId from the sender's -/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master -/// port — the receiver re-derives them from cardId). +/// One revealed card in a knownList. cardId from the sender's deck map; cost +/// is the ENGINE-RESOLVED play-time cost (M-HC-3a) — the discounted cost the headless engine actually +/// charged (spellboost + board modifiers folded in by construction), emitted on EVERY entry (prod sends +/// cost 45/45 in captures, so it is NOT omitted). spellboost still carries the count for now +/// (Task 6 retires that bookkeeping once cost is engine-sourced everywhere). attachTarget stays ""; +/// clan/tribe remain deferred (receiver re-derives them from cardId). public sealed record KnownCardEntry( [property: JsonPropertyName("idx")] int Idx, [property: JsonPropertyName("cardId")] long CardId, [property: JsonPropertyName("to")] int To, [property: JsonPropertyName("spellboost")] int Spellboost, - [property: JsonPropertyName("attachTarget")] string AttachTarget); + [property: JsonPropertyName("attachTarget")] string AttachTarget, + [property: JsonPropertyName("cost")] int Cost); /// Renamed targetList entry. isSelf is actor-relative and passes through /// verbatim — no perspective flip (bullet-3 audit F2). diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index b668119..289dc8d 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -39,11 +39,21 @@ internal sealed class PlayActionsHandler : IFrameHandler ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList); var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From); - // Spellboost count rides the played card's knownList (prod-faithful; the client reads it into the - // card's cost model). Read the CURRENT map (state before this frame's grant) for the emit, then - // fold THIS frame's alter ops in afterwards — a card's cost is fixed as it leaves hand, and a play - // that grants spellboost (e.g. Fate's Hand) targets the REST of the hand, not the card just played. - var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList, ctx.State.GetSpellboostMap(ctx.From)); + // The ENGINE-RESOLVED play-time cost (M-HC-3a). The conductor's ShadowIngest already ran + // engine.Receive for THIS frame before this handler runs, so the engine has resolved the play and + // PlayedCardCost reads the discounted cost it actually charged (spellboost + board modifiers folded + // in BY CONSTRUCTION — no bookkeeping). Sender's seat == ctx.A (BattleSession.ShadowIngest uses the + // same ReferenceEquals(from, A) mapping). Degrades to 0 when the engine isn't owned/ready for this + // session (single-active-engine gate) so a non-engine session never crashes. + bool senderSeat = ReferenceEquals(ctx.From, ctx.A); + int playedCost = ctx.Engine.PlayedCardCost(senderSeat, playIdx, fallback: 0); + + // Spellboost count still rides the played card's knownList (prod-faithful; Task 6 retires this + // bookkeeping now that cost is engine-sourced). Read the CURRENT map (state before this frame's + // grant) for the emit, then fold THIS frame's alter ops in afterwards — a play that grants + // spellboost (e.g. Fate's Hand) targets the REST of the hand, not the card just played. + var played = KnownListBuilder.BuildPlayedCard( + deckMap, playIdx, orderList, ctx.State.GetSpellboostMap(ctx.From), cost: playedCost); ctx.State.RecordSpellboostFrom(ctx.From, ctx.Other, orderList); var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault(WireKeys.TargetList)); diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs index 010afde..86c91c4 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs @@ -16,17 +16,20 @@ internal static class KnownListBuilder /// idx → 0. Prod sends the real count here and the client reads it straight into the card's cost model /// (NetworkBattleReceiver spellboost case), so a wrong value makes the opponent compute the /// card at full price and silently reject the play in OperateReceiveChecker.IsPlayCard - /// (PP-over → ConductError → NullOperationCollection → no render/echo). attachTarget stays ""; - /// cost/clan/tribe remain deferred (receiver re-derives from cardId). + /// (PP-over → ConductError → NullOperationCollection → no render/echo). is the + /// engine-RESOLVED play-time cost (M-HC-3a) the handler reads off the shadow engine and passes in; + /// it lands on the entry verbatim (a vanilla play naturally resolves to its base cost). attachTarget + /// stays ""; clan/tribe remain deferred (receiver re-derives from cardId). public static KnownCardEntry? BuildPlayedCard( IReadOnlyDictionary deckMap, int playIdx, object? orderList, - IReadOnlyDictionary? spellboostMap = null) + IReadOnlyDictionary? spellboostMap = null, int cost = 0) { if (!deckMap.TryGetValue(playIdx, out var cardId)) return null; var to = ExtractMoveTo(orderList, playIdx); if (to is null) return null; var spellboost = spellboostMap is not null && spellboostMap.TryGetValue(playIdx, out var sb) ? sb : 0; - return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: spellboost, AttachTarget: ""); + return new KnownCardEntry( + Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: spellboost, AttachTarget: "", Cost: cost); } /// Mine spellboost-count changes from a sender's orderList alter ops. For each diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index 947295f..28510da 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -10,6 +10,7 @@ using BattlePlayerBase = engine::BattlePlayerBase; using BattleCardBase = engine::BattleCardBase; using ClassBattleCardBase = engine::ClassBattleCardBase; using CardCreatorBase = engine::CardCreatorBase; +using CostAddModifier = engine::CostAddModifier; using SBattleLoad = engine::SBattleLoad; using CardTemplate = engine::CardTemplate; using GameObject = engine::UnityEngine.GameObject; @@ -169,6 +170,69 @@ internal sealed class SessionBattleEngine public int InPlayCardId(bool playerSeat, int boardPos) => Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId; + /// The engine-RESOLVED play-time cost of the card whose engine Index == + /// on (M-HC-3a). This is the discounted cost the play actually paid — + /// spellboost reduction, board-dependent modifiers and all — read straight off the engine, so the + /// opponent-facing knownList carries the SAME cost the engine charged (closing the spellboost + /// cost-desync BY CONSTRUCTION: no bookkeeping, the engine already knows). + /// READ-MOMENT: the conductor's ShadowIngest runs engine.Receive (→ resolves the + /// play) BEFORE the handler runs, so at read time the played card has LEFT the hand — a follower sits + /// in ClassAndInPlayCardList, a spell in CemeteryList. + /// captures _playedCost = useCost (== the fully-resolved Cost at the moment of play, + /// incl. every CostModifier) onto the card object, which persists after the card leaves the hand — + /// so is the authoritative play-time discounted cost. We search + /// the seat's post-resolution zones (in-play, cemetery) by Index, then fall back to the hand + /// (a not-yet-resolved card, e.g. a degenerate test path) reading the live Cost there. + /// Degrades to when the engine is not set up (the single-active-engine + /// gate left this session without an owned engine) or the idx resolves to no card — so a non-engine + /// session never crashes and a vanilla play simply emits its base cost via the caller's fallback. + public int PlayedCardCost(bool playerSeat, int idx, int fallback = 0) + { + if (_mgr is null) return fallback; + var card = FindByIndex(Seat(playerSeat), idx); + if (card is null) return fallback; + // PlayedCost is set (>= 0) once PlayCard resolved the play; before that (a card still in hand on a + // degenerate path) read the live Cost, which already folds in any registered CostModifier. + return card.PlayedCost >= 0 ? card.PlayedCost : card.Cost; + } + + // Locate the card with the given engine Index across the seat's post-resolution zones. Order matters + // only for disambiguation; Index is unique per card so the first hit is the card. In-play (followers) + // and cemetery (spells) are where a just-resolved play lands; hand is the pre-resolution fallback. + private static BattleCardBase? FindByIndex(BattlePlayerBase seat, int idx) + { + foreach (var c in seat.ClassAndInPlayCardList) + if (c.Index == idx) return c; + foreach (var c in seat.CemeteryList) + if (c.Index == idx) return c; + foreach (var c in seat.HandCardList) + if (c.Index == idx) return c; + return null; + } + + /// TEST SEAM (M-HC-3a validation): register a cost-reducing modifier on the hand card at + /// engine Index == , mimicking what card 101314020's when_spell_charge + /// cost_change add=ADD_CHARGE_COUNT*-1 skill does once it has accumulated + /// spellboost charges (each charge adds a CostAddModifier(-1); the engine's own + /// builds exactly this). Used to drive the count→cost resolution + /// deterministically headless without pumping the (VFX-coupled) spell-charge skill chain through a + /// real multi-spell sequence — the engine's authentic getter then + /// resolves the discount, and captures it as PlayedCost on the + /// next play. Returns the resolved hand-card Cost AFTER seeding (base − charge) for the caller to pin. + /// No-op-returns -1 if the engine isn't set up or no hand card has that Index. + internal int SeedHandCardSpellboostCost(bool playerSeat, int idx, int charge) + { + if (_mgr is null) return -1; + BattleCardBase? card = null; + foreach (var c in Seat(playerSeat).HandCardList) + if (c.Index == idx) { card = c; break; } + if (card is null) return -1; + for (int i = 0; i < charge; i++) + card.AddCostModifier(new CostAddModifier(-1), null, eventCall: false); + card.SetSpellChargeCount(charge); // keep the charge count consistent with the modifiers (cosmetic here) + return card.Cost; + } + private engine::BattlePlayerBase Seat(bool playerSeat) => (_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat); diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index 41f2c1d..78f9bb9 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -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; /// TurnEnd) 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 knownList[].cost 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. when_evolve_other) 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. /// [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 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 { 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 HandlerPlayBody(int playIdx) => new() + { + ["playIdx"] = playIdx, + ["type"] = 30, + ["orderList"] = new List + { + new Dictionary + { + ["move"] = new Dictionary + { + ["idx"] = new List { (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"); + } } diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index 1e261e9..0900105 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -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. public const long VanillaFollowerId = 100011010; + /// 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). + 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 /// (PushAsync, RunAsync, TerminateAsync) throw /// — 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. - internal sealed class StubParticipant : IBattleParticipant + internal sealed class StubParticipant : IBattleParticipant, IHasHandshakePhase { public long ViewerId { get; } public MatchContext Context { get; } + /// Handshake cursor (M-HC-3a handler-emit test). Implementing + /// lets a test build a FrameDispatchContext over two + /// StubParticipants and advance both to so + /// BothSidesAfterReady() 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. + public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork; + public StubParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index cb9ca43..6b08852 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -82,6 +82,18 @@ public class KnownListBuilderTests Assert.That(entry.To, Is.EqualTo(20)); Assert.That(entry.Spellboost, Is.EqualTo(0)); Assert.That(entry.AttachTarget, Is.EqualTo("")); + Assert.That(entry.Cost, Is.EqualTo(0), "cost defaults to 0 when the caller passes none"); + } + + [Test] + public void BuildPlayedCard_emits_engine_resolved_cost_passed_by_caller() + { + // 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 { [3] = 101314020L }; + var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), spellboostMap: null, cost: 3); + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Cost, Is.EqualTo(3)); } [Test]