Files
SVSimServer/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs
gamer147 01b0c64a63 fix(battle-node): inject resultCode=1 into every scripted synchronize push
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>
2026-06-01 01:55:35 -04:00

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);
}
}