The client's OnReceived routing drops any synchronize push whose resultCode != Success(1) — and absent counts as 0(None), which is also dropped. Our InitNetwork ack and BattleFinish already included resultCode=1, but the five lifecycle bodies (Matched, BattleStart, Deal, Swap response, Ready) didn't, so the client silently dropped every one of them. Symptom: battle-traffic.ndjson capture showed the client receiving InitNetwork/Matched/BattleStart, but the UI stayed at the matchmaking screen until timeout — Matched/BattleStart were dropped at the routing layer before they ever reached the state machine. Move the resultCode injection into the shared EnvelopeForPush helper so every scripted push gets it. Caught during v1 smoke walkthrough. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
170 lines
6.3 KiB
C#
170 lines
6.3 KiB
C#
using SVSim.BattleNode.Protocol;
|
|
|
|
namespace SVSim.BattleNode.Lifecycle;
|
|
|
|
/// <summary>
|
|
/// v1 Path-A scripted opponent. Hand-rolled static frames good enough to land the client on
|
|
/// the mulligan screen and let them play turn 1. Templates derived from
|
|
/// data_dumps/captures/battle-traffic_tk2_regular.ndjson.
|
|
/// </summary>
|
|
public static class ScriptedLifecycle
|
|
{
|
|
/// <summary>30 dummy cardIds — repeats of a stable neutral card.</summary>
|
|
public static readonly long DummyCardId = 100011010;
|
|
|
|
public const long FakeOpponentViewerId = 999_999_999L;
|
|
|
|
public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId)
|
|
{
|
|
var body = new Dictionary<string, object?>
|
|
{
|
|
["selfInfo"] = new Dictionary<string, object?>
|
|
{
|
|
["country_code"] = "KOR",
|
|
["userName"] = "Player",
|
|
["sleeveId"] = "3000011",
|
|
["emblemId"] = "701441011",
|
|
["degreeId"] = "300003",
|
|
["fieldId"] = 43,
|
|
["isOfficial"] = 0,
|
|
["oppoId"] = opponentViewerId,
|
|
["seed"] = 17548138L,
|
|
},
|
|
["oppoInfo"] = new Dictionary<string, object?>
|
|
{
|
|
["country_code"] = "JPN",
|
|
["userName"] = "Opponent",
|
|
["sleeveId"] = "704141010",
|
|
["emblemId"] = "400001100",
|
|
["degreeId"] = "120027",
|
|
["fieldId"] = 5,
|
|
["isOfficial"] = 0,
|
|
["oppoId"] = playerViewerId,
|
|
["seed"] = 17548138L,
|
|
["oppoDeckCount"] = 30,
|
|
},
|
|
["selfDeck"] = BuildDummyDeck(),
|
|
};
|
|
return EnvelopeForPush(NetworkBattleUri.Matched, body, bid: battleId);
|
|
}
|
|
|
|
public static MsgEnvelope BuildBattleStart(long playerViewerId)
|
|
{
|
|
var body = new Dictionary<string, object?>
|
|
{
|
|
["turnState"] = 0, // player goes first
|
|
["battleType"] = 11, // TK2 NetworkBattleType
|
|
["selfInfo"] = new Dictionary<string, object?>
|
|
{
|
|
["rank"] = "10",
|
|
["battlePoint"] = "6270",
|
|
["classId"] = "1",
|
|
["charaId"] = "1",
|
|
["cardMasterName"] = "card_master_node_10015",
|
|
},
|
|
["oppoInfo"] = new Dictionary<string, object?>
|
|
{
|
|
["rank"] = "1",
|
|
["isMasterRank"] = "0",
|
|
["battlePoint"] = 0,
|
|
["masterPoint"] = "0",
|
|
["classId"] = "8",
|
|
["charaId"] = "8",
|
|
["cardMasterName"] = "card_master_node_10015",
|
|
},
|
|
};
|
|
return EnvelopeForPush(NetworkBattleUri.BattleStart, body);
|
|
}
|
|
|
|
public static MsgEnvelope BuildDeal()
|
|
{
|
|
var body = new Dictionary<string, object?>
|
|
{
|
|
["self"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
|
|
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
|
|
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
|
|
},
|
|
["oppo"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
|
|
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
|
|
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
|
|
},
|
|
};
|
|
return EnvelopeForPush(NetworkBattleUri.Deal, body);
|
|
}
|
|
|
|
public static MsgEnvelope BuildSwapResponse(IReadOnlyList<long> swapIndices)
|
|
{
|
|
// v1: ignore the player's chosen indices and echo the same hand back.
|
|
// (Acceptable because the client doesn't validate which idxs come back — it just renders them.)
|
|
_ = swapIndices;
|
|
var body = new Dictionary<string, object?>
|
|
{
|
|
["self"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
|
|
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
|
|
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
|
|
},
|
|
};
|
|
return EnvelopeForPush(NetworkBattleUri.Swap, body);
|
|
}
|
|
|
|
public static MsgEnvelope BuildReady()
|
|
{
|
|
var body = new Dictionary<string, object?>
|
|
{
|
|
["self"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
|
|
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
|
|
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
|
|
},
|
|
["oppo"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
|
|
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
|
|
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
|
|
},
|
|
["idxChangeSeed"] = 771335280,
|
|
["spin"] = 243,
|
|
};
|
|
return EnvelopeForPush(NetworkBattleUri.Ready, body);
|
|
}
|
|
|
|
private static List<object?> BuildDummyDeck()
|
|
{
|
|
var deck = new List<object?>(30);
|
|
for (var i = 1; i <= 30; i++)
|
|
{
|
|
deck.Add(new Dictionary<string, object?>
|
|
{
|
|
["idx"] = i,
|
|
["cardId"] = DummyCardId,
|
|
});
|
|
}
|
|
return deck;
|
|
}
|
|
|
|
private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, Dictionary<string, object?> body, string? bid = null)
|
|
{
|
|
// Synchronize-push routing in the client's OnReceived drops any frame whose
|
|
// resultCode != Success (1). Absent counts as 0 (None) and is also dropped — so we
|
|
// MUST include it on every scripted push, not just InitNetwork ack / BattleFinish.
|
|
// See server-to-client.md §"Routing in OnReceived" and the matching prod captures.
|
|
body["resultCode"] = (int)ReceiveNodeResultCode.Success;
|
|
return new MsgEnvelope(uri,
|
|
ViewerId: FakeOpponentViewerId,
|
|
Uuid: "node-stub",
|
|
Bid: bid,
|
|
Try: 0,
|
|
Cat: EmitCategory.Battle,
|
|
PubSeq: null,
|
|
PlaySeq: null, // OutboundSequencer.AssignAndArchive stamps this
|
|
Body: body);
|
|
}
|
|
}
|