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:
@@ -36,15 +36,19 @@ public sealed record SelectCardEntry(
|
||||
[property: JsonPropertyName("open")]
|
||||
[property: JsonConverter(typeof(JsonNumberEnumConverter<ChoiceVisibility>))] ChoiceVisibility Open);
|
||||
|
||||
/// <summary>One revealed card in a <c>knownList</c>. 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).</summary>
|
||||
/// <summary>One revealed card in a <c>knownList</c>. <c>cardId</c> from the sender's deck map; <c>cost</c>
|
||||
/// 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). <c>spellboost</c> 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).</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Renamed <c>targetList</c> entry. <c>isSelf</c> is actor-relative and passes through
|
||||
/// verbatim — no perspective flip (bullet-3 audit F2).</summary>
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
/// (<c>NetworkBattleReceiver</c> spellboost case), so a wrong value makes the opponent compute the
|
||||
/// card at full price and silently reject the play in <c>OperateReceiveChecker.IsPlayCard</c>
|
||||
/// (PP-over → ConductError → NullOperationCollection → no render/echo). attachTarget stays "";
|
||||
/// cost/clan/tribe remain deferred (receiver re-derives from cardId).</summary>
|
||||
/// (PP-over → ConductError → NullOperationCollection → no render/echo). <paramref name="cost"/> 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).</summary>
|
||||
public static KnownCardEntry? BuildPlayedCard(
|
||||
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList,
|
||||
IReadOnlyDictionary<int, int>? spellboostMap = null)
|
||||
IReadOnlyDictionary<int, int>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Mine spellboost-count changes from a sender's <c>orderList</c> <c>alter</c> ops. For each
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The engine-RESOLVED play-time cost of the card whose engine <c>Index</c> == <paramref name="idx"/>
|
||||
/// on <paramref name="playerSeat"/> (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).
|
||||
/// <para>READ-MOMENT: the conductor's <c>ShadowIngest</c> runs <c>engine.Receive</c> (→ resolves the
|
||||
/// play) BEFORE the handler runs, so at read time the played card has LEFT the hand — a follower sits
|
||||
/// in <c>ClassAndInPlayCardList</c>, a spell in <c>CemeteryList</c>. <see cref="BattleCardBase.PlayCard"/>
|
||||
/// captures <c>_playedCost = useCost</c> (== the fully-resolved <c>Cost</c> at the moment of play,
|
||||
/// incl. every CostModifier) onto the card object, which persists after the card leaves the hand —
|
||||
/// so <see cref="BattleCardBase.PlayedCost"/> is the authoritative play-time discounted cost. We search
|
||||
/// the seat's post-resolution zones (in-play, cemetery) by <c>Index</c>, then fall back to the hand
|
||||
/// (a not-yet-resolved card, e.g. a degenerate test path) reading the live <c>Cost</c> there.</para>
|
||||
/// <para>Degrades to <paramref name="fallback"/> 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.</para></summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>TEST SEAM (M-HC-3a validation): register a cost-reducing modifier on the hand card at
|
||||
/// engine <c>Index</c> == <paramref name="idx"/>, mimicking what card 101314020's <c>when_spell_charge</c>
|
||||
/// <c>cost_change add=ADD_CHARGE_COUNT*-1</c> skill does once it has accumulated <paramref name="charge"/>
|
||||
/// spellboost charges (each charge adds a <c>CostAddModifier(-1)</c>; the engine's own
|
||||
/// <see cref="Skill_cost_change"/> 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 <see cref="BattleCardBase.Cost"/> getter then
|
||||
/// resolves the discount, and <see cref="BattleCardBase.PlayCard"/> 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.</summary>
|
||||
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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<int, long> { [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]
|
||||
|
||||
Reference in New Issue
Block a user