From d3508d7bd4caa2f30398b3b2b7a1c97106719cef Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 7 Jun 2026 00:23:07 -0400 Subject: [PATCH] fix(battlenode): PlayedCardTribe degrades to 0 not empty; clan/tribe builder tests (M-HC-4e review) Co-Authored-By: Claude Opus 4.8 --- .../Dispatch/Handlers/PlayActionsHandler.cs | 2 +- .../Sessions/Engine/SessionBattleEngine.cs | 15 +++++++---- .../Sessions/KnownListBuilderTests.cs | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index 1f39cdc..4d59dce 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -61,7 +61,7 @@ internal sealed class PlayActionsHandler : IFrameHandler // value would miss it). Prod always emits both on every knownList entry: clan as the int ClanType // ordinal, tribe as the comma-joined int TribeType string ("0" for none). Same senderSeat mapping. int playedClan = ctx.Engine.PlayedCardClan(senderSeat, playIdx, fallback: 0); - string playedTribe = ctx.Engine.PlayedCardTribe(senderSeat, playIdx); + string playedTribe = ctx.Engine.PlayedCardTribe(senderSeat, playIdx, fallback: "0"); var played = KnownListBuilder.BuildPlayedCard( deckMap, playIdx, orderList, cost: playedCost, spellboost: playedSpellboost, diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index 51b58e0..0c8fc5c 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -302,13 +302,18 @@ internal sealed class SessionBattleEngine /// , whose getter folds in any skill-applied tribe CHANGE/ADD over /// BaseParameter.Tribe (and drops ALL when the resolved list has ≥2 entries) — so the wire carries /// the LIVE tribe, the faithful value over the static card-master one. - /// Same post-resolution zone search as ; no engine / no card → "" (an - /// engine that isn't owned this session emits no card, so the caller's BuildPlayedCard never fires). - public string PlayedCardTribe(bool playerSeat, int idx) + /// Same post-resolution zone search + degrade-to- contract as + /// : no engine / no card → (default "0", the + /// prod no-tribe form — NEVER empty, which is wire-illegal: prod always sends tribe as a non-empty string, + /// the client reads it via item.Value.ToString() at NetworkBattleReceiver.cs:2382). The degrade is + /// LIVE, not dead: a second concurrent battle that loses the single-active-engine gate has _mgr is null + /// yet still emits a knownList entry (KnownListBuilder.BuildPlayedCard gates on the deck map, not engine + /// ownership), so this path must hand back a legal wire value. + public string PlayedCardTribe(bool playerSeat, int idx, string fallback = "0") { - if (_mgr is null) return string.Empty; + if (_mgr is null) return fallback; var card = FindByIndex(Seat(playerSeat), idx); - if (card is null) return string.Empty; + if (card is null) return fallback; var tribe = card.Tribe; // Prod's no-tribe form is the single "0" (TribeType.ALL == 0), never an empty string; an empty list // (defensive) renders the same "0". diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index 3a86543..3c04cb9 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -125,6 +125,33 @@ public class KnownListBuilderTests Assert.That(KnownListBuilder.BuildPlayedCard(deckMap, 3, OrderListMove(3, 10, 20))!.Spellboost, Is.EqualTo(0)); } + [Test] + public void BuildPlayedCard_emits_clan_tribe_passed_by_caller() + { + // M-HC-4e: the handler reads the engine-resolved clan/tribe + // (SessionBattleEngine.PlayedCardClan / PlayedCardTribe) and passes them in; BuildPlayedCard lands + // them on the entry verbatim. (A wrong clan/tribe yields a different field — non-vacuity.) + var deckMap = new Dictionary { [3] = 101314020L }; + var entry = KnownListBuilder.BuildPlayedCard( + deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), cost: 3, spellboost: 2, clan: 8, tribe: "7,16"); + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Clan, Is.EqualTo(8)); + Assert.That(entry.Tribe, Is.EqualTo("7,16")); + } + + [Test] + public void BuildPlayedCard_defaults_clan_to_zero_and_tribe_to_string_zero_when_caller_passes_none() + { + // A play whose engine read degraded (single-active-engine gate: _mgr null → the accessor fallback) + // emits clan 0 (ClanType.ALL ordinal) and tribe "0" (the prod no-tribe form, NEVER empty — + // empty is wire-illegal). The param defaults match the accessor fallbacks. + var deckMap = new Dictionary { [3] = 101311010L }; + var entry = KnownListBuilder.BuildPlayedCard(deckMap, 3, OrderListMove(3, 10, 20)); + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.Clan, Is.EqualTo(0)); + Assert.That(entry.Tribe, Is.EqualTo("0")); + } + [Test] public void RenameTargets_passes_isSelf_through_verbatim() {