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()
{