Compare commits
55 Commits
fb1e91cdf1
...
4b38a9d3e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b38a9d3e0 | ||
|
|
ac78e809cd | ||
|
|
ba18790156 | ||
|
|
e9493e24c4 | ||
|
|
b0e3783757 | ||
|
|
f21ab7a38c | ||
|
|
8085119439 | ||
|
|
ca9ad5db8f | ||
|
|
963adbbd1b | ||
|
|
3fe378d801 | ||
|
|
3ccd986e65 | ||
|
|
3feb535072 | ||
|
|
a916afe924 | ||
|
|
3b6b8d3c94 | ||
|
|
e98bd10dbe | ||
|
|
c360d639f2 | ||
|
|
bca94648f7 | ||
|
|
f0026972cb | ||
|
|
f9c671c089 | ||
|
|
58994a53c9 | ||
|
|
3c8a00c928 | ||
|
|
6e85a6b2db | ||
|
|
6b580c622d | ||
|
|
506d286529 | ||
|
|
030d3b8057 | ||
|
|
b295fd8f09 | ||
|
|
486f72f4a0 | ||
|
|
268b864e28 | ||
|
|
503c382646 | ||
|
|
db2f711894 | ||
|
|
aacd7b56ad | ||
|
|
c03fb3c139 | ||
|
|
d35818360f | ||
|
|
538099ff4b | ||
|
|
477faf3df3 | ||
|
|
3e2931b085 | ||
|
|
e5ec8a0de1 | ||
|
|
7c36933c06 | ||
|
|
73d2c4e1b8 | ||
|
|
57d91236a0 | ||
|
|
4f89463f9c | ||
|
|
85c43a9a72 | ||
|
|
95554cee04 | ||
|
|
afe2984075 | ||
|
|
feb387d3d5 | ||
|
|
2d31037648 | ||
|
|
8052ed60ec | ||
|
|
a533e9d89d | ||
|
|
633c29b44f | ||
|
|
ae11fe0957 | ||
|
|
84ed07d3af | ||
|
|
feaa149f04 | ||
|
|
c27bf444a5 | ||
|
|
ae94d62357 | ||
|
|
05d8169012 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -408,4 +408,6 @@ FodyWeavers.xsd
|
|||||||
*.msp
|
*.msp
|
||||||
|
|
||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
*.sln.iml
|
*.sln.iml
|
||||||
|
# Stale editor backups
|
||||||
|
*.bak
|
||||||
|
|||||||
@@ -16,17 +16,6 @@ public sealed class BattleNodeOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dev convenience: when true, matchmaking endpoints that would otherwise park
|
|
||||||
/// a solo poller (returning 3002 RETRY until a partner arrives) instead return
|
|
||||||
/// a Scripted match immediately — equivalent to passing <c>?scripted=1</c> on
|
|
||||||
/// every request. Turn off to test real PvP with two clients. Default false.
|
|
||||||
/// <para>Trade-off: while on, two viewers polling simultaneously each get
|
|
||||||
/// their own Scripted match instead of pairing with each other. Toggling off
|
|
||||||
/// is the only way to get PvP behavior.</para>
|
|
||||||
/// </summary>
|
|
||||||
public bool SoloDefaultsToScripted { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
|
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
|
||||||
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
|
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ public interface IMatchingBridge
|
|||||||
/// connect WS within 60s.</item>
|
/// connect WS within 60s.</item>
|
||||||
/// <item><c>Bot</c>: <paramref name="p2"/> must be null. One viewer expected;
|
/// <item><c>Bot</c>: <paramref name="p2"/> must be null. One viewer expected;
|
||||||
/// opponent runs in client.</item>
|
/// opponent runs in client.</item>
|
||||||
/// <item><c>Scripted</c>: <paramref name="p2"/> currently null; future
|
|
||||||
/// server-driven bot config rides on <paramref name="p2"/>.</item>
|
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type);
|
PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
namespace SVSim.BattleNode.Bridge;
|
namespace SVSim.BattleNode.Bridge;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-battle player snapshot captured at do_matching time and replayed into the scripted
|
/// Per-battle player snapshot captured at do_matching time and replayed into the
|
||||||
/// lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side
|
/// server-authored frame lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side
|
||||||
/// per-mode controller is the source. Snapshot semantics: cosmetic changes between matching
|
/// per-mode controller is the source. Snapshot semantics: cosmetic changes between matching
|
||||||
/// and WS connect have no effect on the in-battle render.
|
/// and WS connect have no effect on the in-battle render.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace SVSim.BattleNode.Bridge;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-process implementation of <see cref="IMatchingBridge"/>. The HTTP-side
|
/// In-process implementation of <see cref="IMatchingBridge"/>. The HTTP-side
|
||||||
/// matching queue calls <see cref="RegisterBattle"/> once it has decided "these two
|
/// matching queue calls <see cref="RegisterBattle"/> once it has decided "these two
|
||||||
/// play each other" or "this viewer is solo (bot/scripted)."
|
/// play each other" or "this viewer is solo (bot)."
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MatchingBridge : IMatchingBridge
|
public sealed class MatchingBridge : IMatchingBridge
|
||||||
{
|
{
|
||||||
@@ -47,9 +47,6 @@ public sealed class MatchingBridge : IMatchingBridge
|
|||||||
case BattleType.Bot:
|
case BattleType.Bot:
|
||||||
if (p2 is not null) throw new ArgumentException("Bot must have p2==null.", nameof(p2));
|
if (p2 is not null) throw new ArgumentException("Bot must have p2==null.", nameof(p2));
|
||||||
break;
|
break;
|
||||||
case BattleType.Scripted:
|
|
||||||
// p2 currently null; future server-driven bot will populate it.
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BattleType.");
|
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BattleType.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,18 +120,6 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
|
|
||||||
switch (pending.Type)
|
switch (pending.Type)
|
||||||
{
|
{
|
||||||
case BattleType.Scripted:
|
|
||||||
{
|
|
||||||
_store.RemovePending(battleId);
|
|
||||||
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
|
||||||
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
|
||||||
var scriptedBot = new ScriptedBotParticipant();
|
|
||||||
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
|
||||||
_loggerFactory.CreateLogger<BattleSession>());
|
|
||||||
await session.RunAsync(ctx.RequestAborted);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case BattleType.Pvp:
|
case BattleType.Pvp:
|
||||||
{
|
{
|
||||||
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
|
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
namespace SVSim.BattleNode.Lifecycle;
|
namespace SVSim.BattleNode.Lifecycle;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Named constants and templates for the v1 scripted lifecycle. Every value here
|
/// Default frame constants templated from TK2 prod captures, shared by the
|
||||||
/// originated in a real prod frame in
|
/// server-authored battle-frame builders. Every value here originated in a real prod
|
||||||
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them out
|
/// frame in <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them
|
||||||
/// of <see cref="ScriptedLifecycle"/> makes the magic numerics navigable and gives
|
/// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable and gives
|
||||||
/// the seed a single source of truth instead of two duplicated literals.
|
/// the seed a single source of truth instead of two duplicated literals.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class ScriptedProfiles
|
internal static class BattleFrameDefaults
|
||||||
{
|
{
|
||||||
// Shared per the spec — selfInfo.seed and oppoInfo.seed always agree.
|
// Shared per the spec — selfInfo.seed and oppoInfo.seed always agree.
|
||||||
// From frame[2] (Matched).
|
// From frame[2] (Matched).
|
||||||
@@ -24,11 +24,6 @@ internal static class ScriptedProfiles
|
|||||||
public const int ReadyIdxChangeSeed = 771_335_280;
|
public const int ReadyIdxChangeSeed = 771_335_280;
|
||||||
public const int ReadySpin = 243;
|
public const int ReadySpin = 243;
|
||||||
|
|
||||||
// Generic non-zero spin that lands the client in "Opponent's turn..."
|
|
||||||
// display state. v1 doesn't simulate the opponent — once this lands,
|
|
||||||
// the client sits there indefinitely.
|
|
||||||
public const int OpponentTurnStartSpin = 100;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's
|
/// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's
|
||||||
/// an animation seed, not a stateful value. Fixed at 100 here for test stability;
|
/// an animation seed, not a stateful value. Fixed at 100 here for test stability;
|
||||||
@@ -6,19 +6,17 @@ using SVSim.BattleNode.Protocol.Bodies;
|
|||||||
namespace SVSim.BattleNode.Lifecycle;
|
namespace SVSim.BattleNode.Lifecycle;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// v1 hand-rolled scripted opponent. Static frame builders for the five lifecycle uris
|
/// Server-authored battle frames pushed to the client during match setup and teardown
|
||||||
/// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart
|
/// (Matched / BattleStart / Deal / Swap response / Ready) plus the post-mulligan hand
|
||||||
/// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2
|
/// computation. Used by every battle mode's handshake/mulligan dispatch arms. Hardcoded
|
||||||
/// captures at <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> — anything
|
/// values are templated from the TK2 prod captures (battle-traffic_tk2_*.ndjson); see
|
||||||
/// hardcoded here came from a real prod frame, with names + provenance in
|
/// <see cref="BattleFrameDefaults"/> for provenance.
|
||||||
/// <see cref="ScriptedProfiles"/>. The player-half of Matched/BattleStart now reads from
|
|
||||||
/// <see cref="MatchContext"/> instead of <see cref="ScriptedProfiles"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ScriptedLifecycle
|
public static class ServerBattleFrames
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real
|
/// Viewer id we present as the opponent on every server-authored opponent push. Out-of-range
|
||||||
/// viewer ids so it can't collide with a real account in the auth pipeline.
|
/// vs. real viewer ids so it can't collide with a real account in the auth pipeline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const long FakeOpponentViewerId = 999_999_999L;
|
public const long FakeOpponentViewerId = 999_999_999L;
|
||||||
|
|
||||||
@@ -53,14 +51,14 @@ public static class ScriptedLifecycle
|
|||||||
bid: battleId);
|
bid: battleId);
|
||||||
|
|
||||||
public static MsgEnvelope BuildBattleStart(
|
public static MsgEnvelope BuildBattleStart(
|
||||||
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId) =>
|
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, int turnState) =>
|
||||||
EnvelopeForPush(NetworkBattleUri.BattleStart,
|
EnvelopeForPush(NetworkBattleUri.BattleStart,
|
||||||
new BattleStartBody(
|
new BattleStartBody(
|
||||||
TurnState: 0, // player goes first
|
TurnState: turnState, // 0 = this side goes first, 1 = second. Caller decides.
|
||||||
BattleType: selfCtx.BattleType,
|
BattleType: selfCtx.BattleType,
|
||||||
SelfInfo: new BattleStartSelfInfo(
|
SelfInfo: new BattleStartSelfInfo(
|
||||||
Rank: ScriptedProfiles.PlayerRank,
|
Rank: BattleFrameDefaults.PlayerRank,
|
||||||
BattlePoint: ScriptedProfiles.PlayerBattlePoint,
|
BattlePoint: BattleFrameDefaults.PlayerBattlePoint,
|
||||||
ClassId: selfCtx.ClassId,
|
ClassId: selfCtx.ClassId,
|
||||||
CharaId: selfCtx.CharaId,
|
CharaId: selfCtx.CharaId,
|
||||||
CardMasterName: selfCtx.CardMasterName),
|
CardMasterName: selfCtx.CardMasterName),
|
||||||
@@ -113,42 +111,19 @@ public static class ScriptedLifecycle
|
|||||||
EnvelopeForPush(NetworkBattleUri.Swap,
|
EnvelopeForPush(NetworkBattleUri.Swap,
|
||||||
new SwapResponseBody(Self: BuildPosIdxList(hand)));
|
new SwapResponseBody(Self: BuildPosIdxList(hand)));
|
||||||
|
|
||||||
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) =>
|
/// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder
|
||||||
|
/// <see cref="InitialHand"/>.</summary>
|
||||||
|
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) => BuildReady(hand, InitialHand);
|
||||||
|
|
||||||
|
/// <summary>Both hands known (the mulligan barrier supplies the opponent's
|
||||||
|
/// post-mulligan hand).</summary>
|
||||||
|
public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand) =>
|
||||||
EnvelopeForPush(NetworkBattleUri.Ready,
|
EnvelopeForPush(NetworkBattleUri.Ready,
|
||||||
new ReadyBody(
|
new ReadyBody(
|
||||||
Self: BuildPosIdxList(hand),
|
Self: BuildPosIdxList(selfHand),
|
||||||
Oppo: BuildPosIdxList(InitialHand),
|
Oppo: BuildPosIdxList(oppoHand),
|
||||||
IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed,
|
IdxChangeSeed: BattleFrameDefaults.ReadyIdxChangeSeed,
|
||||||
Spin: ScriptedProfiles.ReadySpin));
|
Spin: BattleFrameDefaults.ReadySpin));
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// First half of the v1.1 scripted opponent turn cycle: pushed after the player's
|
|
||||||
/// TurnEnd, transitions the client into "Opponent's turn…" state. Paired with
|
|
||||||
/// <see cref="BuildOpponentTurnEnd"/>, which immediately follows and hands control
|
|
||||||
/// back to the player.
|
|
||||||
/// </summary>
|
|
||||||
public static MsgEnvelope BuildOpponentTurnStart() =>
|
|
||||||
EnvelopeForPush(NetworkBattleUri.TurnStart,
|
|
||||||
new OpponentTurnStartBody(Spin: ScriptedProfiles.OpponentTurnStartSpin));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server-pushed TurnEnd transition that closes the opponent's turn and hands control
|
|
||||||
/// back to the player. Paired with <see cref="BuildOpponentTurnStart"/> in the v1.1 loop.
|
|
||||||
/// Wire shape from prod capture battle-traffic_tk2_regular.ndjson L18:
|
|
||||||
/// <c>{"uri":"TurnEnd","turnState":0,"resultCode":1,"playSeq":N}</c>.
|
|
||||||
/// </summary>
|
|
||||||
public static MsgEnvelope BuildOpponentTurnEnd() =>
|
|
||||||
EnvelopeForPush(NetworkBattleUri.TurnEnd, new TurnEndBody(TurnState: 0));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server-pushed Judge frame that follows the opponent's TurnEnd and unblocks the
|
|
||||||
/// client's <c>JudgeOperation</c> → <c>ControlTurnStartPlayer</c>, transitioning to the
|
|
||||||
/// player's next turn. Without this frame the client hangs on "Opponent's turn…" —
|
|
||||||
/// see <c>data_dumps/captures/battle-traffic.ndjson</c> line 14 (client emits its own
|
|
||||||
/// Judge then waits forever).
|
|
||||||
/// </summary>
|
|
||||||
public static MsgEnvelope BuildOpponentJudge() =>
|
|
||||||
EnvelopeForPush(NetworkBattleUri.Judge, new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
|
|
||||||
|
|
||||||
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
|
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
|
||||||
{
|
{
|
||||||
30
SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
Normal file
30
SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
/// <summary>Opponent-facing PlayActions frame the node synthesizes from the active player's
|
||||||
|
/// send. <c>KnownList</c> reveals the played card's identity (null = token reveal deferred, see
|
||||||
|
/// the deterministic-turn slice). <c>OppoTargetList</c> is the renamed <c>targetList</c>
|
||||||
|
/// (independent of KnownList — a targeted hand play carries both). Both omitted when null via the
|
||||||
|
/// envelope's WhenWritingNull policy.</summary>
|
||||||
|
public sealed record PlayActionsBroadcastBody(
|
||||||
|
[property: JsonPropertyName("playIdx")] int PlayIdx,
|
||||||
|
[property: JsonPropertyName("type")] int Type,
|
||||||
|
[property: JsonPropertyName("knownList")] IReadOnlyList<KnownCardEntry>? KnownList,
|
||||||
|
[property: JsonPropertyName("oppoTargetList")] IReadOnlyList<OppoTargetEntry>? OppoTargetList) : IMsgBody;
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Renamed <c>targetList</c> entry. <c>isSelf</c> is actor-relative and passes through
|
||||||
|
/// verbatim — no perspective flip (bullet-3 audit F2).</summary>
|
||||||
|
public sealed record OppoTargetEntry(
|
||||||
|
[property: JsonPropertyName("targetIdx")] int TargetIdx,
|
||||||
|
[property: JsonPropertyName("isSelf")] int IsSelf);
|
||||||
@@ -3,7 +3,7 @@ namespace SVSim.BattleNode.Protocol;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marker for every type that can appear as <see cref="MsgEnvelope.Body"/>.
|
/// Marker for every type that can appear as <see cref="MsgEnvelope.Body"/>.
|
||||||
/// Implementers fall into two camps: typed records used on the outbound path
|
/// Implementers fall into two camps: typed records used on the outbound path
|
||||||
/// (one per scripted frame shape) and <see cref="RawBody"/> used on the inbound
|
/// (one per server-authored frame shape) and <see cref="RawBody"/> used on the inbound
|
||||||
/// path. The marker exists so the envelope can carry either without falling
|
/// path. The marker exists so the envelope can carry either without falling
|
||||||
/// back to <c>object</c>.
|
/// back to <c>object</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SVSim.BattleNode.Lifecycle;
|
|
||||||
using SVSim.BattleNode.Protocol;
|
using SVSim.BattleNode.Protocol;
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
using SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
using SVSim.BattleNode.Sessions.Participants;
|
using SVSim.BattleNode.Sessions.Participants;
|
||||||
|
|
||||||
namespace SVSim.BattleNode.Sessions;
|
namespace SVSim.BattleNode.Sessions;
|
||||||
@@ -13,19 +13,54 @@ namespace SVSim.BattleNode.Sessions;
|
|||||||
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
|
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Phase 1 wires this for <see cref="BattleType.Scripted"/> only — the dispatch logic
|
/// Wires both battle modes: Pvp (broadcast Matched/BattleStart per-perspective, forward
|
||||||
/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective,
|
/// gameplay frames between the two real participants) and Bot (ack-only, NoOp opponent).
|
||||||
/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only).
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class BattleSession
|
public sealed class BattleSession
|
||||||
{
|
{
|
||||||
private readonly ILogger<BattleSession> _log;
|
private readonly ILogger<BattleSession> _log;
|
||||||
|
|
||||||
|
private readonly BattleSessionState _state = new();
|
||||||
|
|
||||||
public string BattleId { get; }
|
public string BattleId { get; }
|
||||||
public BattleType Type { get; }
|
public BattleType Type { get; }
|
||||||
public IBattleParticipant A { get; }
|
public IBattleParticipant A { get; }
|
||||||
public IBattleParticipant B { get; }
|
public IBattleParticipant B { get; }
|
||||||
public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork;
|
public BattleSessionPhase Phase => _state.SessionPhase;
|
||||||
|
|
||||||
|
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
|
||||||
|
// URIs are dropped with a LogDebug in ComputeFrames.
|
||||||
|
private static readonly IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> Handlers = BuildHandlers();
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<NetworkBattleUri, IFrameHandler> BuildHandlers()
|
||||||
|
{
|
||||||
|
var retireKill = new RetireKillHandler();
|
||||||
|
var forwardWhenReady = new ForwardWhenBothReadyHandler();
|
||||||
|
return new Dictionary<NetworkBattleUri, IFrameHandler>
|
||||||
|
{
|
||||||
|
[NetworkBattleUri.InitNetwork] = new InitNetworkHandler(),
|
||||||
|
[NetworkBattleUri.InitBattle] = new InitBattleHandler(),
|
||||||
|
[NetworkBattleUri.Loaded] = new LoadedHandler(),
|
||||||
|
[NetworkBattleUri.Swap] = new SwapHandler(),
|
||||||
|
[NetworkBattleUri.TurnEnd] = new TurnEndHandler(),
|
||||||
|
[NetworkBattleUri.TurnEndFinal] = new TurnEndFinalHandler(),
|
||||||
|
[NetworkBattleUri.Retire] = retireKill,
|
||||||
|
[NetworkBattleUri.Kill] = retireKill,
|
||||||
|
[NetworkBattleUri.TurnStart] = new TurnStartHandler(),
|
||||||
|
[NetworkBattleUri.Judge] = new JudgeHandler(),
|
||||||
|
[NetworkBattleUri.PlayActions] = new PlayActionsHandler(),
|
||||||
|
[NetworkBattleUri.Echo] = new EchoHandler(),
|
||||||
|
[NetworkBattleUri.TurnEndActions] = new TurnEndActionsHandler(),
|
||||||
|
[NetworkBattleUri.JudgeResult] = forwardWhenReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private FrameDispatchContext BuildContext(IBattleParticipant from, MsgEnvelope env) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
|
||||||
|
Env = env, Type = Type, BattleId = BattleId, State = _state,
|
||||||
|
};
|
||||||
|
|
||||||
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
|
||||||
ILogger<BattleSession> log)
|
ILogger<BattleSession> log)
|
||||||
@@ -49,10 +84,9 @@ public sealed class BattleSession
|
|||||||
|
|
||||||
if (Type == BattleType.Pvp)
|
if (Type == BattleType.Pvp)
|
||||||
{
|
{
|
||||||
// WhenAny: first WS drop / first graceful close triggers cascade.
|
// WhenAny: first WS drop / first graceful close triggers cascade. Pvp has two
|
||||||
// ScriptedBotParticipant.RunAsync also returns immediately; that's not used
|
// RealParticipants; we synthesize a BattleFinish for the survivor if either side
|
||||||
// here (Pvp has two RealParticipants), but we'd still want a synthesized
|
// terminates first.
|
||||||
// BattleFinish for the survivor if either side terminates first.
|
|
||||||
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
|
||||||
var survivor = first == aTask ? B : A;
|
var survivor = first == aTask ? B : A;
|
||||||
|
|
||||||
@@ -64,7 +98,7 @@ public sealed class BattleSession
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await survivor.PushAsync(
|
await survivor.PushAsync(
|
||||||
BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
|
BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -73,7 +107,7 @@ public sealed class BattleSession
|
|||||||
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
|
||||||
BattleId);
|
BattleId);
|
||||||
}
|
}
|
||||||
Phase = BattleSessionPhase.Terminal;
|
_state.SessionPhase = BattleSessionPhase.Terminal;
|
||||||
}
|
}
|
||||||
|
|
||||||
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
cts.Cancel(); // unblock the survivor's RunAsync read loop
|
||||||
@@ -82,8 +116,8 @@ public sealed class BattleSession
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Phase 1 semantics for Scripted/Bot: wait for ALL participants. The bot's
|
// Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real
|
||||||
// RunAsync returns immediately; the session keeps running for the real one.
|
// participant. The session keeps running for the real one.
|
||||||
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
|
||||||
catch { /* swallow */ }
|
catch { /* swallow */ }
|
||||||
}
|
}
|
||||||
@@ -126,268 +160,14 @@ public sealed class BattleSession
|
|||||||
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
|
||||||
/// standing up real participants.
|
/// standing up real participants.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames(
|
internal IReadOnlyList<DispatchRoute> ComputeFrames(IBattleParticipant from, MsgEnvelope env)
|
||||||
IBattleParticipant from, MsgEnvelope env)
|
|
||||||
{
|
{
|
||||||
var result = new List<(IBattleParticipant, MsgEnvelope, bool)>();
|
if (Handlers.TryGetValue(env.Uri, out var handler))
|
||||||
var other = ReferenceEquals(from, A) ? B : A;
|
return handler.Handle(BuildContext(from, env));
|
||||||
var phaseFrom = from as IHasHandshakePhase;
|
|
||||||
|
|
||||||
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
|
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
||||||
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. Handshake-phase
|
BattleId, env.Uri, Phase, from.ViewerId);
|
||||||
// arms read the SENDER's Phase (per-participant); the session-level Phase
|
return Array.Empty<DispatchRoute>();
|
||||||
// remains only for the Terminal short-circuit.
|
|
||||||
switch (env.Uri)
|
|
||||||
{
|
|
||||||
case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork:
|
|
||||||
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
|
|
||||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
|
|
||||||
break;
|
|
||||||
|
|
||||||
// --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so they
|
|
||||||
// win pattern matching on Type == Bot. Bot mode: ack handshake, silent
|
|
||||||
// Loaded, Judge-to-sender on TurnEnd. The rest reuse Scripted's arms
|
|
||||||
// (Retire/Kill → BattleFinishNoContest, Swap → per-sender response,
|
|
||||||
// default → drop). Reference: docs/api-spec/in-battle/ai-passive.md.
|
|
||||||
//
|
|
||||||
// Critically, do NOT push Matched or BattleStart for Bot mode. The
|
|
||||||
// architecture spec was right about this:
|
|
||||||
// 1. The client's MatchingInitBattle (Matching.cs:298) immediately calls
|
|
||||||
// StartBattleLoad + GotoBattle on the IsAINetwork branch right after
|
|
||||||
// emitting InitBattle — it does NOT wait for a wire Matched or
|
|
||||||
// BattleStart envelope. The state-machine trigger is _initNetworkSuccess
|
|
||||||
// (set when InitNetwork uri is received, i.e., our ack).
|
|
||||||
// 2. Sending Matched is harmless (gated on status == Connect, which is
|
|
||||||
// already past by the time the wire round-trip completes).
|
|
||||||
// 3. Sending BattleStart is ACTIVELY HARMFUL: its handler at
|
|
||||||
// Matching.cs:417 runs unconditionally and SetNetworkInfo
|
|
||||||
// (RealTimeNetworkAgent.cs:1553-1564) overwrites OppoBattleStartInfo
|
|
||||||
// with the wire envelope's oppoInfo. Our oppoInfo comes from
|
|
||||||
// NoOpBotParticipant.Context placeholders (classId:0, emblemId:0,
|
|
||||||
// etc.), corrupting the good values the client just set from the
|
|
||||||
// HTTP /ai_<fmt>_rank_battle/start response — subsequent asset
|
|
||||||
// loads (LoadOpponentAssets at SBattleLoad.cs:933) then look up
|
|
||||||
// non-existent assets and silently hang on "Waiting for opponent."
|
|
||||||
|
|
||||||
case NetworkBattleUri.InitBattle
|
|
||||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
|
||||||
// Ack only — NO Matched push.
|
|
||||||
result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true));
|
|
||||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NetworkBattleUri.Loaded
|
|
||||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
|
|
||||||
// Silent — no BattleStart, no Deal. The client's AINetworkBattleManager
|
|
||||||
// populates opponent state from AIBattleStart HTTP data; pushing
|
|
||||||
// BattleStart here overwrites that state with zeros.
|
|
||||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NetworkBattleUri.TurnEnd
|
|
||||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
|
||||||
case NetworkBattleUri.TurnEndFinal
|
|
||||||
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
|
||||||
// Judge to sender ONLY (not broadcast — there's no real other side).
|
|
||||||
// The client's JudgeOperation → ControlTurnStartPlayer flips back to
|
|
||||||
// the local AI's turn after this Judge arrives.
|
|
||||||
result.Add((from, BuildJudgeBroadcast(), false));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
|
|
||||||
// Phase 1: push Matched only to the "real" participant. The session reads
|
|
||||||
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
|
|
||||||
// bot's Context fixture preserves the prod-captured cosmetics that previously
|
|
||||||
// lived in ScriptedProfiles).
|
|
||||||
result.Add((from, ScriptedLifecycle.BuildMatched(
|
|
||||||
from.Context, other.Context,
|
|
||||||
from.ViewerId, other.ViewerId,
|
|
||||||
BattleId, ScriptedProfiles.BattleSeed), false));
|
|
||||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
|
|
||||||
result.Add((from, ScriptedLifecycle.BuildBattleStart(
|
|
||||||
from.Context, other.Context, from.ViewerId), false));
|
|
||||||
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
|
|
||||||
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap:
|
|
||||||
{
|
|
||||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
|
|
||||||
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
|
|
||||||
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
|
|
||||||
phaseFrom!.Phase = BattleSessionPhase.AfterReady;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular TurnEnd: continues the game. Scripted forwards to bot for the 3-frame
|
|
||||||
// burst; PvP broadcasts; Bot stays silent.
|
|
||||||
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
|
||||||
if (Type == BattleType.Pvp && BothAfterReady())
|
|
||||||
{
|
|
||||||
var turnEndBroadcast = BuildTurnEndBroadcast();
|
|
||||||
var judgeBroadcast = BuildJudgeBroadcast();
|
|
||||||
result.Add((from, turnEndBroadcast, false));
|
|
||||||
result.Add((other, turnEndBroadcast, false));
|
|
||||||
result.Add((from, judgeBroadcast, false));
|
|
||||||
result.Add((other, judgeBroadcast, false));
|
|
||||||
}
|
|
||||||
else if (Type == BattleType.Scripted)
|
|
||||||
{
|
|
||||||
result.Add((other, env, false));
|
|
||||||
}
|
|
||||||
// Bot type: no-op (NoOpBot swallows; client handles its own turn end).
|
|
||||||
break;
|
|
||||||
|
|
||||||
// TurnEndFinal: client signals the player's FINAL turn is over (game-end
|
|
||||||
// condition met, usually killed opponent's leader). Unified across types:
|
|
||||||
// forward the envelope to other (matches prod TK2 capture
|
|
||||||
// battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal
|
|
||||||
// from server before BattleFinish), then push BattleFinish per-side with
|
|
||||||
// player-perspective codes (LifeWin to winner, LifeLose to loser).
|
|
||||||
// ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) —
|
|
||||||
// this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal
|
|
||||||
// so the RunAsync cascade doesn't synthesize a follow-up BattleFinish.
|
|
||||||
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
|
||||||
result.Add((other, env, false));
|
|
||||||
result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
|
|
||||||
result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true));
|
|
||||||
Phase = BattleSessionPhase.Terminal;
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Retire / Kill: sender concedes (Retire) or the client requested an immediate
|
|
||||||
// terminate (Kill). Unified across types: push BattleFinish per-side with the
|
|
||||||
// proper retire codes. Bots swallow their push (no real-opponent state).
|
|
||||||
case NetworkBattleUri.Retire:
|
|
||||||
case NetworkBattleUri.Kill:
|
|
||||||
result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
|
|
||||||
result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true));
|
|
||||||
Phase = BattleSessionPhase.Terminal;
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward
|
|
||||||
// to the real participant. These match the v1.2 burst's three outbound pushes.
|
|
||||||
// Pre-migration this arm only handled TurnStart/Judge because the handshake
|
|
||||||
// TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd.
|
|
||||||
// Post-migration that arm gates on the sender's per-participant Phase, which the
|
|
||||||
// bot doesn't have, so the bot's TurnEnd now lands here.
|
|
||||||
// The `IsRealForwardableFromScripted` guard ensures this arm matches ONLY the
|
|
||||||
// scripted bot's emissions (sender ViewerId == FakeOpponentViewerId) — without
|
|
||||||
// it, a TurnStart/TurnEnd/Judge from a real participant in PvP mode would match
|
|
||||||
// here and `goto default` would skip the PvP forwarder arm below.
|
|
||||||
case NetworkBattleUri.TurnStart when IsRealForwardableFromScripted(from, env):
|
|
||||||
case NetworkBattleUri.TurnEnd when IsRealForwardableFromScripted(from, env):
|
|
||||||
case NetworkBattleUri.Judge when IsRealForwardableFromScripted(from, env):
|
|
||||||
// Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart,
|
|
||||||
// TurnEnd, and Judge are intended for the real participant.
|
|
||||||
result.Add((other, env, false));
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Gameplay-frame forwarding (post-AfterReady). Unified across types:
|
|
||||||
// BothAfterReady() is only true when both participants are RealParticipants
|
|
||||||
// (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
|
|
||||||
// always null), so this arm naturally fires for PvP only. Order matters:
|
|
||||||
// this MUST come after the FakeOpponentViewerId arms so Scripted bot
|
|
||||||
// emissions don't fall into this forwarder.
|
|
||||||
case NetworkBattleUri.TurnStart when BothAfterReady():
|
|
||||||
case NetworkBattleUri.PlayActions when BothAfterReady():
|
|
||||||
case NetworkBattleUri.Echo when BothAfterReady():
|
|
||||||
case NetworkBattleUri.TurnEndActions when BothAfterReady():
|
|
||||||
case NetworkBattleUri.JudgeResult when BothAfterReady():
|
|
||||||
result.Add((other, env, false));
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
|
|
||||||
BattleId, env.Uri, Phase, from.ViewerId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: the only "scripted-bot" emissions we need to forward are the three burst
|
|
||||||
// frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch
|
|
||||||
// above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases
|
|
||||||
// above only fire when the source is actually a participant (not malformed inbound).
|
|
||||||
private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env)
|
|
||||||
{
|
|
||||||
// The bot's emitted frames carry ViewerId == FakeOpponentViewerId.
|
|
||||||
return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: PvP gameplay-frame forwarding is gated on BOTH sides having completed
|
|
||||||
// the handshake (i.e. reached AfterReady). Until then, an early TurnStart/PlayActions
|
|
||||||
// from one side has no valid recipient.
|
|
||||||
private bool BothAfterReady() =>
|
|
||||||
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
|
|
||||||
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
|
|
||||||
|
|
||||||
private MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
|
|
||||||
uri,
|
|
||||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
|
||||||
Uuid: WireConstants.ServerUuid,
|
|
||||||
Bid: null,
|
|
||||||
Try: 0,
|
|
||||||
Cat: EmitCategory.General,
|
|
||||||
PubSeq: null,
|
|
||||||
PlaySeq: null,
|
|
||||||
Body: new ResultCodeOnlyBody());
|
|
||||||
|
|
||||||
private MsgEnvelope BuildTurnEndBroadcast() => new(
|
|
||||||
NetworkBattleUri.TurnEnd,
|
|
||||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
|
||||||
Uuid: WireConstants.ServerUuid,
|
|
||||||
Bid: null,
|
|
||||||
Try: 0,
|
|
||||||
Cat: EmitCategory.Battle,
|
|
||||||
PubSeq: null,
|
|
||||||
PlaySeq: null,
|
|
||||||
Body: new TurnEndBody(TurnState: 0));
|
|
||||||
|
|
||||||
private MsgEnvelope BuildJudgeBroadcast() => new(
|
|
||||||
NetworkBattleUri.Judge,
|
|
||||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
|
||||||
Uuid: WireConstants.ServerUuid,
|
|
||||||
Bid: null,
|
|
||||||
Try: 0,
|
|
||||||
Cat: EmitCategory.Battle,
|
|
||||||
PubSeq: null,
|
|
||||||
PlaySeq: null,
|
|
||||||
Body: new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
|
|
||||||
|
|
||||||
private MsgEnvelope BuildBattleFinish(BattleResult result) => new(
|
|
||||||
NetworkBattleUri.BattleFinish,
|
|
||||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
|
||||||
Uuid: WireConstants.ServerUuid,
|
|
||||||
Bid: null,
|
|
||||||
Try: 0,
|
|
||||||
Cat: EmitCategory.Battle,
|
|
||||||
PubSeq: null,
|
|
||||||
PlaySeq: null,
|
|
||||||
Body: new BattleFinishBody(Result: result));
|
|
||||||
|
|
||||||
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
|
||||||
{
|
|
||||||
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
|
||||||
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
|
|
||||||
{
|
|
||||||
var result = new List<long>();
|
|
||||||
foreach (var item in seq)
|
|
||||||
{
|
|
||||||
switch (item)
|
|
||||||
{
|
|
||||||
case long l: result.Add(l); break;
|
|
||||||
case int i: result.Add(i); break;
|
|
||||||
case double d: result.Add((long)d); break;
|
|
||||||
case decimal m: result.Add((long)m); break;
|
|
||||||
case string s when long.TryParse(s, out var p): result.Add(p); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return Array.Empty<long>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
namespace SVSim.BattleNode.Sessions;
|
namespace SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where we are in the v1 scripted lifecycle. Drives which scripted frames the session pushes
|
/// Where we are in the v1 server-authored frame lifecycle. Drives which server-authored frames
|
||||||
/// in response to inbound emits.
|
/// the session pushes in response to inbound emits.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum BattleSessionPhase
|
public enum BattleSessionPhase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,9 +14,4 @@ public enum BattleType
|
|||||||
/// path; matched only in rank rotation / rank unlimited per prod). Server is
|
/// path; matched only in rank rotation / rank unlimited per prod). Server is
|
||||||
/// ack-only. <c>p2</c> must be null.</summary>
|
/// ack-only. <c>p2</c> must be null.</summary>
|
||||||
Bot,
|
Bot,
|
||||||
|
|
||||||
/// <summary>One real player; server scripts the opponent (today's v1.2
|
|
||||||
/// behaviour, preserved as a solo testing harness). <c>p2</c> currently null;
|
|
||||||
/// future server-driven bot config can ride on <c>p2</c>.</summary>
|
|
||||||
Scripted,
|
|
||||||
}
|
}
|
||||||
|
|||||||
76
SVSim.BattleNode/Sessions/Dispatch/BattleFrames.cs
Normal file
76
SVSim.BattleNode/Sessions/Dispatch/BattleFrames.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>Server-synthesized control/broadcast frames + inbound-body helpers, relocated verbatim
|
||||||
|
/// from BattleSession so the per-URI handlers can build them. Pure: no session state.</summary>
|
||||||
|
internal static class BattleFrames
|
||||||
|
{
|
||||||
|
internal static MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
|
||||||
|
uri,
|
||||||
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
|
Uuid: WireConstants.ServerUuid,
|
||||||
|
Bid: null,
|
||||||
|
Try: 0,
|
||||||
|
Cat: EmitCategory.General,
|
||||||
|
PubSeq: null,
|
||||||
|
PlaySeq: null,
|
||||||
|
Body: new ResultCodeOnlyBody());
|
||||||
|
|
||||||
|
internal static MsgEnvelope BuildTurnEndBroadcast() => new(
|
||||||
|
NetworkBattleUri.TurnEnd,
|
||||||
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
|
Uuid: WireConstants.ServerUuid,
|
||||||
|
Bid: null,
|
||||||
|
Try: 0,
|
||||||
|
Cat: EmitCategory.Battle,
|
||||||
|
PubSeq: null,
|
||||||
|
PlaySeq: null,
|
||||||
|
Body: new TurnEndBody(TurnState: 0));
|
||||||
|
|
||||||
|
internal static MsgEnvelope BuildJudgeBroadcast() => new(
|
||||||
|
NetworkBattleUri.Judge,
|
||||||
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
|
Uuid: WireConstants.ServerUuid,
|
||||||
|
Bid: null,
|
||||||
|
Try: 0,
|
||||||
|
Cat: EmitCategory.Battle,
|
||||||
|
PubSeq: null,
|
||||||
|
PlaySeq: null,
|
||||||
|
Body: new JudgeBody(Spin: BattleFrameDefaults.OpponentJudgeSpin));
|
||||||
|
|
||||||
|
internal static MsgEnvelope BuildBattleFinish(BattleResult result) => new(
|
||||||
|
NetworkBattleUri.BattleFinish,
|
||||||
|
ViewerId: ServerBattleFrames.FakeOpponentViewerId,
|
||||||
|
Uuid: WireConstants.ServerUuid,
|
||||||
|
Bid: null,
|
||||||
|
Try: 0,
|
||||||
|
Cat: EmitCategory.Battle,
|
||||||
|
PubSeq: null,
|
||||||
|
PlaySeq: null,
|
||||||
|
Body: new BattleFinishBody(Result: result));
|
||||||
|
|
||||||
|
internal static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
|
||||||
|
{
|
||||||
|
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
|
||||||
|
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
|
||||||
|
{
|
||||||
|
var result = new List<long>();
|
||||||
|
foreach (var item in seq)
|
||||||
|
{
|
||||||
|
switch (item)
|
||||||
|
{
|
||||||
|
case long l: result.Add(l); break;
|
||||||
|
case int i: result.Add(i); break;
|
||||||
|
case double d: result.Add((long)d); break;
|
||||||
|
case decimal m: result.Add((long)m); break;
|
||||||
|
case string s when long.TryParse(s, out var p): result.Add(p); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return Array.Empty<long>();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
Normal file
31
SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using SVSim.BattleNode.Sessions;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>Mutable per-session state shared across frame handlers. The mulligan barrier's
|
||||||
|
/// post-swap hands, plus (PvP-equivalency, vanilla slice) the per-side idx->cardId map used to
|
||||||
|
/// synthesize the opponent-facing <c>knownList</c>. FUTURE: a token map (cardIds mined from
|
||||||
|
/// orderList <c>add</c> ops, idx>30) + a reveal-gate set land alongside <see cref="IdxToCardId"/>.</summary>
|
||||||
|
internal sealed class BattleSessionState
|
||||||
|
{
|
||||||
|
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
||||||
|
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
|
||||||
|
/// Deck cards only (idx 1..deckCount); tokens (idx>deckCount) are deferred.</summary>
|
||||||
|
public Dictionary<IBattleParticipant, Dictionary<int, long>> IdxToCardId { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The sender's idx->cardId map, seeding it from its <see cref="MatchContext"/> on first
|
||||||
|
/// use. <c>BuildPlayerDeck</c> assigns deck idx = position+1, so entry (i+1) -> cardIds[i].</summary>
|
||||||
|
public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side)
|
||||||
|
{
|
||||||
|
if (!IdxToCardId.TryGetValue(side, out var map))
|
||||||
|
{
|
||||||
|
map = new Dictionary<int, long>();
|
||||||
|
var deck = side.Context.SelfDeckCardIds;
|
||||||
|
for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
|
||||||
|
IdxToCardId[side] = map;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs
Normal file
8
SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>One routing decision: deliver <paramref name="Frame"/> to <paramref name="Target"/>.
|
||||||
|
/// Named form of the tuple <c>ComputeFrames</c> historically returned. <paramref name="NoStock"/>
|
||||||
|
/// true for control frames (BattleFinish, ack) — bypasses playSeq assignment + archive.</summary>
|
||||||
|
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock);
|
||||||
34
SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs
Normal file
34
SVSim.BattleNode/Sessions/Dispatch/FrameDispatchContext.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Sessions.Participants; // IHasHandshakePhase
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>Everything a handler reads or mutates for one inbound frame. <see cref="A"/>/<see cref="B"/>
|
||||||
|
/// are the session's positional participants (preserved so handlers that iterate participants in a
|
||||||
|
/// stable order — e.g. the mulligan barrier — match the legacy switch byte-for-byte). <see cref="From"/>
|
||||||
|
/// is the sender; <see cref="Other"/> is the non-sender.</summary>
|
||||||
|
internal sealed class FrameDispatchContext
|
||||||
|
{
|
||||||
|
internal required IBattleParticipant A { get; init; }
|
||||||
|
internal required IBattleParticipant B { get; init; }
|
||||||
|
internal required IBattleParticipant From { get; init; }
|
||||||
|
internal required IBattleParticipant Other { get; init; }
|
||||||
|
internal required MsgEnvelope Env { get; init; }
|
||||||
|
internal required BattleType Type { get; init; }
|
||||||
|
internal required string BattleId { get; init; }
|
||||||
|
internal required BattleSessionState State { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
|
||||||
|
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
|
||||||
|
internal BattleSessionPhase? SenderPhase
|
||||||
|
{
|
||||||
|
get => (From as IHasHandshakePhase)?.Phase;
|
||||||
|
set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Both participants have completed the handshake. Reads A/B (not From/Other) so the
|
||||||
|
/// result is identical regardless of which side sent the frame — matches legacy BothAfterReady.</summary>
|
||||||
|
internal bool BothAfterReady() =>
|
||||||
|
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
|
||||||
|
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
/// <summary>Echo is the receiver's per-frame ack; the client has no inbound Echo handler, so the
|
||||||
|
/// node consumes it (bullet-2 audit). Relaying would risk an echo->echo storm.</summary>
|
||||||
|
internal sealed class EchoHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) => Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class ForwardWhenBothReadyHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.BothAfterReady())
|
||||||
|
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class InitBattleHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
// case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info).
|
||||||
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
||||||
|
{
|
||||||
|
var r = new List<DispatchRoute>
|
||||||
|
{
|
||||||
|
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), true),
|
||||||
|
};
|
||||||
|
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// case 5: general — push Matched (per-perspective) to the sender only.
|
||||||
|
if (ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle)
|
||||||
|
{
|
||||||
|
var r = new List<DispatchRoute>
|
||||||
|
{
|
||||||
|
new(ctx.From, ServerBattleFrames.BuildMatched(
|
||||||
|
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId,
|
||||||
|
ctx.BattleId, BattleFrameDefaults.BattleSeed), false),
|
||||||
|
};
|
||||||
|
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class InitNetworkHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.SenderPhase != BattleSessionPhase.AwaitingInitNetwork)
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
|
var routes = new List<DispatchRoute>
|
||||||
|
{
|
||||||
|
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), true),
|
||||||
|
};
|
||||||
|
ctx.SenderPhase = BattleSessionPhase.AwaitingInitBattle;
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs
Normal file
25
SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class JudgeHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
// PvP: Judge is the handover gate. The player who sends Judge is the one TAKING OVER the
|
||||||
|
// turn (the client rule is: receive opponent TurnEnd -> SendJudge). Receiving Judge{spin}
|
||||||
|
// fires ControlTurnStartPlayer ("start MY turn"), so the {spin} must REFLECT BACK to the
|
||||||
|
// sender — NOT go to the opponent (that would make the player who just ended their turn
|
||||||
|
// start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture).
|
||||||
|
// The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}.
|
||||||
|
// battleCode is dropped; spin=0 for the deterministic-turn slice.
|
||||||
|
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||||
|
{
|
||||||
|
var frame = ctx.Env with { Body = new JudgeBody(Spin: 0) };
|
||||||
|
return new[] { new DispatchRoute(ctx.From, frame, false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs
Normal file
34
SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class LoadedHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
// case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data).
|
||||||
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
||||||
|
{
|
||||||
|
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// case 6: general — BattleStart (per-perspective) + Deal to the sender.
|
||||||
|
if (ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded)
|
||||||
|
{
|
||||||
|
// A goes first deterministically (turnState 0); B goes second (turnState 1).
|
||||||
|
var turnState = ReferenceEquals(ctx.From, ctx.A) ? 0 : 1;
|
||||||
|
var r = new List<DispatchRoute>
|
||||||
|
{
|
||||||
|
new(ctx.From, ServerBattleFrames.BuildBattleStart(
|
||||||
|
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), false),
|
||||||
|
new(ctx.From, ServerBattleFrames.BuildDeal(), false),
|
||||||
|
};
|
||||||
|
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
/// <summary>PvP PlayActions translator (vanilla deck-card slice). Synthesizes the opponent-facing
|
||||||
|
/// knownList from the sender's idx->cardId map + the orderList move op, renames targetList ->
|
||||||
|
/// oppoTargetList, drops orderList, consumes keyAction. Token plays (idx>deck) degrade silently to
|
||||||
|
/// {playIdx,type} (no knownList). Bot drop (no rule).</summary>
|
||||||
|
internal sealed class PlayActionsHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady())
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
|
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();
|
||||||
|
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx"));
|
||||||
|
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
|
||||||
|
|
||||||
|
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
||||||
|
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, entries.GetValueOrDefault("orderList"));
|
||||||
|
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
|
||||||
|
|
||||||
|
var body = new PlayActionsBroadcastBody(
|
||||||
|
PlayIdx: playIdx,
|
||||||
|
Type: type,
|
||||||
|
KnownList: played is null ? null : new[] { played },
|
||||||
|
OppoTargetList: oppoTargets);
|
||||||
|
|
||||||
|
var frame = ctx.Env with { Body = body };
|
||||||
|
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class RetireKillHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), true),
|
||||||
|
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs
Normal file
39
SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using SVSim.BattleNode.Lifecycle;
|
||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Sessions.Participants; // IHasHandshakePhase
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class SwapHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.SenderPhase != BattleSessionPhase.AwaitingSwap)
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
|
||||||
|
var routes = new List<DispatchRoute>();
|
||||||
|
var hand = ServerBattleFrames.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(ctx.Env));
|
||||||
|
|
||||||
|
// SwapResponse is always immediate — completes the sender's own mulligan UI.
|
||||||
|
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), false));
|
||||||
|
ctx.State.PostSwapHands[ctx.From] = hand;
|
||||||
|
ctx.SenderPhase = BattleSessionPhase.AfterReady;
|
||||||
|
|
||||||
|
// Release Ready to every swapper once all handshake-driving participants have swapped.
|
||||||
|
// IHasHandshakePhase membership IS the "participates in mulligan" set.
|
||||||
|
var swappers = new[] { ctx.A, ctx.B }.Where(p => p is IHasHandshakePhase).ToList();
|
||||||
|
if (swappers.All(ctx.State.PostSwapHands.ContainsKey))
|
||||||
|
{
|
||||||
|
foreach (var p in swappers)
|
||||||
|
{
|
||||||
|
var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A;
|
||||||
|
var ready = opponent is IHasHandshakePhase
|
||||||
|
&& ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand)
|
||||||
|
? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand)
|
||||||
|
: ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p]);
|
||||||
|
routes.Add(new DispatchRoute(p, ready, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
/// <summary>PvP TurnEndActions: the sender's orderList is dropped; the opponent receives an
|
||||||
|
/// empty body (it only flips _sendEcho + runs the opponent's end-of-turn triggers via the
|
||||||
|
/// opponent's own engine). Bot drop.</summary>
|
||||||
|
internal sealed class TurnEndActionsHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||||
|
{
|
||||||
|
var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) };
|
||||||
|
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||||
|
}
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class TurnEndFinalHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
// case 4: Bot — Judge to sender only.
|
||||||
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
||||||
|
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) };
|
||||||
|
|
||||||
|
// case 9: general — forward the envelope to other + paired BattleFinish + Terminal.
|
||||||
|
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
||||||
|
{
|
||||||
|
ctx.State.SessionPhase = BattleSessionPhase.Terminal;
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new DispatchRoute(ctx.Other, ctx.Env, false),
|
||||||
|
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), true),
|
||||||
|
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class TurnEndHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
// case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI).
|
||||||
|
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
||||||
|
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) };
|
||||||
|
|
||||||
|
// case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent
|
||||||
|
// (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame.
|
||||||
|
if (ctx.SenderPhase == BattleSessionPhase.AfterReady)
|
||||||
|
{
|
||||||
|
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||||
|
{
|
||||||
|
// Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
|
||||||
|
// the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects
|
||||||
|
// back to it to start its turn. battleCode/actionSeq/cemetery are dropped.
|
||||||
|
var te = ctx.Env with { Body = new TurnEndBody(TurnState: 0) };
|
||||||
|
return new[] { new DispatchRoute(ctx.Other, te, false) };
|
||||||
|
}
|
||||||
|
return Array.Empty<DispatchRoute>(); // Pvp-not-both-ready → drop (Bot already returned above)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using SVSim.BattleNode.Protocol;
|
||||||
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||||
|
|
||||||
|
internal sealed class TurnStartHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||||
|
{
|
||||||
|
// PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin}
|
||||||
|
// (spin=0 for the deterministic-turn slice) and self-generates its turn-open.
|
||||||
|
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||||
|
{
|
||||||
|
var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: 0) };
|
||||||
|
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<DispatchRoute>();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SVSim.BattleNode/Sessions/Dispatch/IFrameHandler.cs
Normal file
10
SVSim.BattleNode/Sessions/Dispatch/IFrameHandler.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>Handles one (or more) inbound URI(s). Pure: returns the routes to dispatch and may
|
||||||
|
/// mutate <see cref="FrameDispatchContext.State"/> / advance <see cref="FrameDispatchContext.SenderPhase"/>,
|
||||||
|
/// but does not touch the wire. Stateless singletons live in BattleSession's registry; a single
|
||||||
|
/// handler may be registered under multiple URIs (e.g. Retire/Kill).</summary>
|
||||||
|
internal interface IFrameHandler
|
||||||
|
{
|
||||||
|
IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx);
|
||||||
|
}
|
||||||
74
SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
Normal file
74
SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using SVSim.BattleNode.Protocol.Bodies;
|
||||||
|
|
||||||
|
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
/// <summary>Pure transforms from the active player's RawBody sub-structures to the opponent-facing
|
||||||
|
/// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive
|
||||||
|
/// as <c>Dictionary<string,object?></c> / <c>List<object?></c> with numeric leaves boxed
|
||||||
|
/// as long/int/double (see MsgEnvelope.FromJson).</summary>
|
||||||
|
internal static class KnownListBuilder
|
||||||
|
{
|
||||||
|
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized
|
||||||
|
/// (token idx not in the deck map, or no matching move op). spellboost/attachTarget default to
|
||||||
|
/// 0/"" for the vanilla slice; cost/clan/tribe are deferred (receiver re-derives from cardId).</summary>
|
||||||
|
public static KnownCardEntry? BuildPlayedCard(
|
||||||
|
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList)
|
||||||
|
{
|
||||||
|
if (!deckMap.TryGetValue(playIdx, out var cardId)) return null;
|
||||||
|
var to = ExtractMoveTo(orderList, playIdx);
|
||||||
|
if (to is null) return null;
|
||||||
|
return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: 0, AttachTarget: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The <c>to</c> place-state of the FIRST <c>move</c> op whose <c>idx</c> list contains
|
||||||
|
/// <paramref name="playIdx"/> (the played card's own move; later add/alter ops are the deferred
|
||||||
|
/// token slice), or null if absent. NOTE: the sender-side <c>to</c> is passed through verbatim —
|
||||||
|
/// for the vanilla slice we assume send-side and recv-side place-state codes match, pending
|
||||||
|
/// recv-capture confirmation.</summary>
|
||||||
|
public static int? ExtractMoveTo(object? orderList, int playIdx)
|
||||||
|
{
|
||||||
|
if (orderList is not IEnumerable<object?> ops) return null;
|
||||||
|
foreach (var op in ops)
|
||||||
|
{
|
||||||
|
if (op is not IDictionary<string, object?> opDict) continue;
|
||||||
|
if (!opDict.TryGetValue("move", out var moveRaw) || moveRaw is not IDictionary<string, object?> move) continue;
|
||||||
|
if (move.TryGetValue("idx", out var idxRaw) && idxRaw is IEnumerable<object?> idxList)
|
||||||
|
{
|
||||||
|
foreach (var i in idxList)
|
||||||
|
if (AsLong(i) == playIdx && move.TryGetValue("to", out var toRaw))
|
||||||
|
return (int)AsLong(toRaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rename <c>targetList</c> -> <c>oppoTargetList</c>; <c>isSelf</c> is actor-relative
|
||||||
|
/// and passes through unchanged (F2). Null for a missing/empty list.</summary>
|
||||||
|
public static IReadOnlyList<OppoTargetEntry>? RenameTargets(object? targetList)
|
||||||
|
{
|
||||||
|
if (targetList is not IEnumerable<object?> entries) return null;
|
||||||
|
var result = new List<OppoTargetEntry>();
|
||||||
|
foreach (var e in entries)
|
||||||
|
{
|
||||||
|
if (e is not IDictionary<string, object?> d) continue;
|
||||||
|
d.TryGetValue("targetIdx", out var targetIdxRaw);
|
||||||
|
d.TryGetValue("isSelf", out var isSelfRaw);
|
||||||
|
result.Add(new OppoTargetEntry(
|
||||||
|
TargetIdx: (int)AsLong(targetIdxRaw),
|
||||||
|
IsSelf: (int)AsLong(isSelfRaw)));
|
||||||
|
}
|
||||||
|
return result.Count == 0 ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Coerce a boxed RawBody numeric leaf (long/int/double/decimal/string) to long; 0 for
|
||||||
|
/// null/unparseable.</summary>
|
||||||
|
public static long AsLong(object? value) => value switch
|
||||||
|
{
|
||||||
|
long l => l,
|
||||||
|
int i => i,
|
||||||
|
double d => (long)d,
|
||||||
|
decimal m => (long)m,
|
||||||
|
string s when long.TryParse(s, out var p) => p,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,18 +5,16 @@ namespace SVSim.BattleNode.Sessions;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One side of a battle. Two of these are held by a <c>BattleSession</c>; the session
|
/// One side of a battle. Two of these are held by a <c>BattleSession</c>; the session
|
||||||
/// brokers between them. Concrete impls (added in subsequent Phase-1 tasks):
|
/// brokers between them. Concrete impls:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>RealParticipant</c> — WS-backed.</item>
|
/// <item><c>RealParticipant</c> — WS-backed (used for <c>BattleType.Pvp</c>).</item>
|
||||||
/// <item><c>NoOpBotParticipant</c> — silent; for <c>BattleType.Bot</c> (AI-passive).</item>
|
/// <item><c>NoOpBotParticipant</c> — silent; for <c>BattleType.Bot</c> (AI-passive).</item>
|
||||||
/// <item><c>ScriptedBotParticipant</c> — wraps the v1.2 lifecycle for
|
|
||||||
/// <c>BattleType.Scripted</c> (solo testing harness).</item>
|
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBattleParticipant : IAsyncDisposable
|
public interface IBattleParticipant : IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <summary>Real viewer id, or a synthetic stable id for bots
|
/// <summary>Real viewer id, or a synthetic stable id for bots
|
||||||
/// (<see cref="Lifecycle.ScriptedLifecycle.FakeOpponentViewerId"/>).</summary>
|
/// (<see cref="Lifecycle.ServerBattleFrames.FakeOpponentViewerId"/>).</summary>
|
||||||
long ViewerId { get; }
|
long ViewerId { get; }
|
||||||
|
|
||||||
/// <summary>Per-battle MatchContext snapshot, used for building Matched/BattleStart
|
/// <summary>Per-battle MatchContext snapshot, used for building Matched/BattleStart
|
||||||
@@ -25,19 +23,17 @@ public interface IBattleParticipant : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>Session calls this to deliver a frame from the OTHER participant
|
/// <summary>Session calls this to deliver a frame from the OTHER participant
|
||||||
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
|
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
|
||||||
/// NoOp: swallow. Scripted: may emit a response via <see cref="FrameEmitted"/>.</summary>
|
/// NoOp: swallow.</summary>
|
||||||
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack);
|
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack);
|
||||||
/// bypasses playSeq assignment + archive.</param>
|
/// bypasses playSeq assignment + archive.</param>
|
||||||
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct);
|
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>Participant fires this when it has a frame to send TO the session
|
/// <summary>Participant fires this when it has a frame to send TO the session
|
||||||
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.
|
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.</summary>
|
||||||
/// Scripted: fires from inside PushAsync when the scripted lifecycle wants to
|
|
||||||
/// respond to an inbound frame.</summary>
|
|
||||||
event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
|
|
||||||
/// <summary>Drives the participant's inbound loop. For Real: the WS read loop
|
/// <summary>Drives the participant's inbound loop. For Real: the WS read loop
|
||||||
/// (returns when the WS closes). For NoOp/Scripted: completes immediately (the
|
/// (returns when the WS closes). For NoOp: completes immediately (the
|
||||||
/// session keeps running as long as the OTHER participant's RunAsync is alive).</summary>
|
/// session keeps running as long as the OTHER participant's RunAsync is alive).</summary>
|
||||||
Task RunAsync(CancellationToken ct);
|
Task RunAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ namespace SVSim.BattleNode.Sessions.Participants;
|
|||||||
/// Silent participant — produces no frames, swallows everything pushed to it.
|
/// Silent participant — produces no frames, swallows everything pushed to it.
|
||||||
/// Used as the "other" participant in <see cref="BattleType.Bot"/> sessions, where
|
/// Used as the "other" participant in <see cref="BattleType.Bot"/> sessions, where
|
||||||
/// the real opponent runs in the client and the server has no opponent-side state
|
/// the real opponent runs in the client and the server has no opponent-side state
|
||||||
/// to model. ViewerId is <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>;
|
/// to model. ViewerId is <see cref="ServerBattleFrames.FakeOpponentViewerId"/>;
|
||||||
/// Context is a fixed stub (irrelevant — never read because no frames are pushed
|
/// Context is a fixed stub (irrelevant — never read because no frames are pushed
|
||||||
/// to the other side).
|
/// to the other side).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NoOpBotParticipant : IBattleParticipant
|
public sealed class NoOpBotParticipant : IBattleParticipant
|
||||||
{
|
{
|
||||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
public long ViewerId => ServerBattleFrames.FakeOpponentViewerId;
|
||||||
public MatchContext Context { get; } = new(
|
public MatchContext Context { get; } = new(
|
||||||
SelfDeckCardIds: Array.Empty<long>(),
|
SelfDeckCardIds: Array.Empty<long>(),
|
||||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
/// (used by its stockEmitMessageMgr.GetSelectData lookup); it's NOT on the wire.
|
/// (used by its stockEmitMessageMgr.GetSelectData lookup); it's NOT on the wire.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// In scripted/Bot mode the server has no opponent to forward touches to; ack-only is
|
/// In Bot mode the server has no opponent to forward touches to; ack-only is
|
||||||
/// correct. PvP-side forwarding semantics are unverified — see
|
/// correct. PvP-side forwarding semantics are unverified — see
|
||||||
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
|
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -369,7 +369,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
}
|
}
|
||||||
var aliveEnv = new MsgEnvelope(
|
var aliveEnv = new MsgEnvelope(
|
||||||
Uri: NetworkBattleUri.Gungnir,
|
Uri: NetworkBattleUri.Gungnir,
|
||||||
ViewerId: SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId,
|
ViewerId: SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId,
|
||||||
Uuid: WireConstants.ServerUuid,
|
Uuid: WireConstants.ServerUuid,
|
||||||
Bid: null,
|
Bid: null,
|
||||||
Try: 0,
|
Try: 0,
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using SVSim.BattleNode.Bridge;
|
|
||||||
using SVSim.BattleNode.Lifecycle;
|
|
||||||
using SVSim.BattleNode.Protocol;
|
|
||||||
|
|
||||||
namespace SVSim.BattleNode.Sessions.Participants;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server-scripted opponent (today's v1.2 testing-harness behavior, repackaged).
|
|
||||||
/// On <see cref="PushAsync"/> with <c>TurnEnd</c> or <c>TurnEndFinal</c>, fires
|
|
||||||
/// <see cref="FrameEmitted"/> three times: <c>OpponentTurnStart</c>,
|
|
||||||
/// <c>OpponentTurnEnd</c>, <c>OpponentJudge</c>. All other URIs are swallowed
|
|
||||||
/// (no opponent reaction needed for v1.2 behavior).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// ViewerId, Context are fixtures matching <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>
|
|
||||||
/// and a scripted opponent profile. The Context fixture is the source of truth for the
|
|
||||||
/// scripted opponent's half of Matched and BattleStart (cosmetics, deck count, class/chara) —
|
|
||||||
/// <see cref="BattleSession.ComputeFrames"/> reads <c>other.Context</c> for those frames.
|
|
||||||
/// Deal still uses fixed scripted frames that ignore Context.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class ScriptedBotParticipant : IBattleParticipant
|
|
||||||
{
|
|
||||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
|
||||||
public MatchContext Context { get; } = new(
|
|
||||||
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 — prod frame[2] (Matched)
|
|
||||||
// shipped OppoDeckCount: 30.
|
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
|
||||||
// BattleStart opponent half (frame[5]): ClassId/CharaId both "8" (neutral test class).
|
|
||||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
|
||||||
// Matched opponent half (frame[2]): cosmetic fields from the prod capture.
|
|
||||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
|
||||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
|
||||||
BattleType: 0);
|
|
||||||
|
|
||||||
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
|
||||||
|
|
||||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// React to the player's TurnEnd with the three-frame burst (TurnStart / TurnEnd /
|
|
||||||
// Judge) — that's the v1.2 "scripted bot takes its turn" behavior. Everything else
|
|
||||||
// (including TurnEndFinal) is silently swallowed: TurnEndFinal is the player's
|
|
||||||
// game-end signal and is handled directly by the BattleSession dispatch arm, which
|
|
||||||
// pushes BattleFinish per-side; the bot doesn't need to react.
|
|
||||||
if (envelope.Uri is NetworkBattleUri.TurnEnd)
|
|
||||||
{
|
|
||||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
|
|
||||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
|
|
||||||
await EmitAsync(ScriptedLifecycle.BuildOpponentJudge(), ct).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
|
|
||||||
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
||||||
|
|
||||||
private Task EmitAsync(MsgEnvelope env, CancellationToken ct) =>
|
|
||||||
FrameEmitted?.Invoke(env, ct) ?? Task.CompletedTask;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -36,7 +37,7 @@ public class AchievementCatalogImporter
|
|||||||
};
|
};
|
||||||
entry.Name = s.Name;
|
entry.Name = s.Name;
|
||||||
entry.RequireNumber = s.RequireNumber;
|
entry.RequireNumber = s.RequireNumber;
|
||||||
entry.RewardType = s.RewardType;
|
entry.RewardType = (UserGoodsType)s.RewardType;
|
||||||
entry.RewardDetailId = s.RewardDetailId;
|
entry.RewardDetailId = s.RewardDetailId;
|
||||||
entry.RewardNumber = s.RewardNumber;
|
entry.RewardNumber = s.RewardNumber;
|
||||||
entry.OrderNum = s.OrderNum;
|
entry.OrderNum = s.OrderNum;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -27,7 +28,7 @@ public class ArenaTwoPickRewardImporter
|
|||||||
int upserted = 0;
|
int upserted = 0;
|
||||||
foreach (var s in seeds)
|
foreach (var s in seeds)
|
||||||
{
|
{
|
||||||
if (existing.TryGetValue((s.WinCount, s.RewardGroup, s.RewardType, s.RewardId, s.RewardNum), out var row))
|
if (existing.TryGetValue((s.WinCount, s.RewardGroup, (UserGoodsType)s.RewardType, s.RewardId, s.RewardNum), out var row))
|
||||||
{
|
{
|
||||||
row.Weight = s.Weight;
|
row.Weight = s.Weight;
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ public class ArenaTwoPickRewardImporter
|
|||||||
WinCount = s.WinCount,
|
WinCount = s.WinCount,
|
||||||
RewardGroup = s.RewardGroup,
|
RewardGroup = s.RewardGroup,
|
||||||
Weight = s.Weight,
|
Weight = s.Weight,
|
||||||
RewardType = s.RewardType,
|
RewardType = (UserGoodsType)s.RewardType,
|
||||||
RewardId = s.RewardId,
|
RewardId = s.RewardId,
|
||||||
RewardNum = s.RewardNum,
|
RewardNum = s.RewardNum,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -38,7 +39,7 @@ public class BattlePassMonthlyMissionImporter
|
|||||||
entry.Name = s.Name;
|
entry.Name = s.Name;
|
||||||
entry.RequireNumber = s.RequireNumber;
|
entry.RequireNumber = s.RequireNumber;
|
||||||
entry.BattlePassPoint = s.BattlePassPoint;
|
entry.BattlePassPoint = s.BattlePassPoint;
|
||||||
entry.RewardType = s.RewardType;
|
entry.RewardType = (UserGoodsType?)s.RewardType;
|
||||||
entry.RewardDetailId = s.RewardDetailId;
|
entry.RewardDetailId = s.RewardDetailId;
|
||||||
entry.RewardNumber = s.RewardNumber;
|
entry.RewardNumber = s.RewardNumber;
|
||||||
entry.EventType = s.EventType;
|
entry.EventType = s.EventType;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public class BattlePassRewardImporter
|
|||||||
seenKeys.Add(key);
|
seenKeys.Add(key);
|
||||||
if (dbByKey.TryGetValue(key, out var ex))
|
if (dbByKey.TryGetValue(key, out var ex))
|
||||||
{
|
{
|
||||||
ex.RewardType = s.RewardType;
|
ex.RewardType = (UserGoodsType)s.RewardType;
|
||||||
ex.RewardDetailId = s.RewardDetailId;
|
ex.RewardDetailId = s.RewardDetailId;
|
||||||
ex.RewardNumber = s.RewardNumber;
|
ex.RewardNumber = s.RewardNumber;
|
||||||
ex.IsAppealExclusion = s.IsAppealExclusion;
|
ex.IsAppealExclusion = s.IsAppealExclusion;
|
||||||
@@ -50,7 +50,7 @@ public class BattlePassRewardImporter
|
|||||||
{
|
{
|
||||||
Id = MakeId(s.SeasonId, track, s.Level),
|
Id = MakeId(s.SeasonId, track, s.Level),
|
||||||
SeasonId = s.SeasonId, Track = track, Level = s.Level,
|
SeasonId = s.SeasonId, Track = track, Level = s.Level,
|
||||||
RewardType = s.RewardType, RewardDetailId = s.RewardDetailId,
|
RewardType = (UserGoodsType)s.RewardType, RewardDetailId = s.RewardDetailId,
|
||||||
RewardNumber = s.RewardNumber, IsAppealExclusion = s.IsAppealExclusion,
|
RewardNumber = s.RewardNumber, IsAppealExclusion = s.IsAppealExclusion,
|
||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -174,7 +175,7 @@ public class BuildDeckImporter
|
|||||||
{
|
{
|
||||||
TierIndex = r.TierIndex,
|
TierIndex = r.TierIndex,
|
||||||
ItemIndex = r.ItemIndex,
|
ItemIndex = r.ItemIndex,
|
||||||
RewardType = r.RewardType,
|
RewardType = (UserGoodsType)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
MessageId = r.MessageId,
|
MessageId = r.MessageId,
|
||||||
@@ -208,7 +209,7 @@ public class BuildDeckImporter
|
|||||||
productRow.Rewards.Add(new BuildDeckProductRewardEntry
|
productRow.Rewards.Add(new BuildDeckProductRewardEntry
|
||||||
{
|
{
|
||||||
RewardIndex = r.RewardIndex,
|
RewardIndex = r.RewardIndex,
|
||||||
RewardType = r.RewardType,
|
RewardType = (UserGoodsType)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
MessageId = r.MessageId,
|
MessageId = r.MessageId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -61,7 +62,7 @@ public class LeaderSkinShopImporter
|
|||||||
series.SetCompletionRewards.Add(new LeaderSkinShopSeriesRewardEntry
|
series.SetCompletionRewards.Add(new LeaderSkinShopSeriesRewardEntry
|
||||||
{
|
{
|
||||||
OrderIndex = r.OrderIndex,
|
OrderIndex = r.OrderIndex,
|
||||||
RewardType = r.RewardType,
|
RewardType = (UserGoodsType)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
});
|
});
|
||||||
@@ -98,7 +99,7 @@ public class LeaderSkinShopImporter
|
|||||||
product.Rewards.Add(new LeaderSkinShopProductRewardEntry
|
product.Rewards.Add(new LeaderSkinShopProductRewardEntry
|
||||||
{
|
{
|
||||||
OrderIndex = r.OrderIndex,
|
OrderIndex = r.OrderIndex,
|
||||||
RewardType = r.RewardType,
|
RewardType = (UserGoodsType)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -30,7 +31,7 @@ public class MissionCatalogImporter
|
|||||||
entry.Name = s.Name;
|
entry.Name = s.Name;
|
||||||
entry.LotType = s.LotType;
|
entry.LotType = s.LotType;
|
||||||
entry.RequireNumber = s.RequireNumber;
|
entry.RequireNumber = s.RequireNumber;
|
||||||
entry.RewardType = s.RewardType;
|
entry.RewardType = (UserGoodsType)s.RewardType;
|
||||||
entry.RewardDetailId = s.RewardDetailId;
|
entry.RewardDetailId = s.RewardDetailId;
|
||||||
entry.RewardNumber = s.RewardNumber;
|
entry.RewardNumber = s.RewardNumber;
|
||||||
entry.BattlePassPoint = s.BattlePassPoint;
|
entry.BattlePassPoint = s.BattlePassPoint;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -121,7 +122,7 @@ public class PuzzleImporter
|
|||||||
entry.RequireNumber = s.RequireNumber;
|
entry.RequireNumber = s.RequireNumber;
|
||||||
entry.CampaignCommenceTime = s.CampaignCommenceTime;
|
entry.CampaignCommenceTime = s.CampaignCommenceTime;
|
||||||
entry.OrderId = s.OrderId;
|
entry.OrderId = s.OrderId;
|
||||||
entry.RewardType = s.RewardType;
|
entry.RewardType = (UserGoodsType)s.RewardType;
|
||||||
entry.RewardDetailId = s.RewardDetailId;
|
entry.RewardDetailId = s.RewardDetailId;
|
||||||
entry.RewardNumber = s.RewardNumber;
|
entry.RewardNumber = s.RewardNumber;
|
||||||
entry.TargetPuzzleGroupId = s.TargetPuzzleGroupId;
|
entry.TargetPuzzleGroupId = s.TargetPuzzleGroupId;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Bootstrap.Models.Seed;
|
using SVSim.Bootstrap.Models.Seed;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
@@ -72,7 +73,7 @@ public class SleeveShopImporter
|
|||||||
product.Rewards.Add(new SleeveShopProductRewardEntry
|
product.Rewards.Add(new SleeveShopProductRewardEntry
|
||||||
{
|
{
|
||||||
OrderIndex = r.OrderIndex,
|
OrderIndex = r.OrderIndex,
|
||||||
RewardType = r.RewardType,
|
RewardType = (UserGoodsType)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
using SVSim.Database.Entities.Story;
|
using SVSim.Database.Entities.Story;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Bootstrap.Importers;
|
namespace SVSim.Bootstrap.Importers;
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ public class StoryImporter
|
|||||||
foreach (var r in c.StoryReward ?? new())
|
foreach (var r in c.StoryReward ?? new())
|
||||||
row.Rewards.Add(new StoryChapterReward
|
row.Rewards.Add(new StoryChapterReward
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType,
|
RewardType = (UserGoodsType)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Entities.Story;
|
namespace SVSim.Database.Entities.Story;
|
||||||
|
|
||||||
[Microsoft.EntityFrameworkCore.Owned]
|
[Microsoft.EntityFrameworkCore.Owned]
|
||||||
public class StoryChapterReward
|
public class StoryChapterReward
|
||||||
{
|
{
|
||||||
public int RewardType { get; set; }
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using SVSim.Database.Common;
|
using SVSim.Database.Common;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ public class AchievementCatalogEntry
|
|||||||
public int Level { get; set; }
|
public int Level { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public int RequireNumber { get; set; }
|
public int RequireNumber { get; set; }
|
||||||
public int RewardType { get; set; }
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
public int OrderNum { get; set; }
|
public int OrderNum { get; set; }
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class ArenaTwoPickReward
|
|||||||
public int Weight { get; set; } = 1;
|
public int Weight { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary><see cref="UserGoodsType"/> on the wire (e.g. Item=4, Rupy=9).</summary>
|
/// <summary><see cref="UserGoodsType"/> on the wire (e.g. Item=4, Rupy=9).</summary>
|
||||||
public int RewardType { get; set; }
|
public UserGoodsType RewardType { get; set; }
|
||||||
|
|
||||||
/// <summary>Item id for Item; 0 for currencies.</summary>
|
/// <summary>Item id for Item; 0 for currencies.</summary>
|
||||||
public long RewardId { get; set; }
|
public long RewardId { get; set; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using SVSim.Database.Common;
|
using SVSim.Database.Common;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ public class BattlePassMonthlyMissionEntry : BaseEntity<int>
|
|||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public int RequireNumber { get; set; }
|
public int RequireNumber { get; set; }
|
||||||
public int BattlePassPoint { get; set; }
|
public int BattlePassPoint { get; set; }
|
||||||
public int? RewardType { get; set; }
|
public UserGoodsType? RewardType { get; set; }
|
||||||
public long? RewardDetailId { get; set; }
|
public long? RewardDetailId { get; set; }
|
||||||
public int? RewardNumber { get; set; }
|
public int? RewardNumber { get; set; }
|
||||||
public string? EventType { get; set; }
|
public string? EventType { get; set; }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class BattlePassRewardEntry : BaseEntity<long>
|
|||||||
public int SeasonId { get; set; }
|
public int SeasonId { get; set; }
|
||||||
public BattlePassTrack Track { get; set; }
|
public BattlePassTrack Track { get; set; }
|
||||||
public int Level { get; set; }
|
public int Level { get; set; }
|
||||||
public int RewardType { get; set; }
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
public bool IsAppealExclusion { get; set; }
|
public bool IsAppealExclusion { get; set; }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace SVSim.Database.Models;
|
|||||||
///
|
///
|
||||||
/// Cosmetic ids (sleeve / emblem / degree / field) MUST resolve in
|
/// Cosmetic ids (sleeve / emblem / degree / field) MUST resolve in
|
||||||
/// <c>SBattleLoad.LoadOpponentAssets</c>; placeholder 1s left the client hanging on
|
/// <c>SBattleLoad.LoadOpponentAssets</c>; placeholder 1s left the client hanging on
|
||||||
/// "Waiting for opponent". Prod-verified values come from the Scripted bot fixture.
|
/// "Waiting for opponent". Prod-verified values were captured from live prod traffic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BotRosterEntry : BaseEntity<int>
|
public class BotRosterEntry : BaseEntity<int>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ namespace SVSim.Database.Models;
|
|||||||
public class BuildDeckProductRewardEntry
|
public class BuildDeckProductRewardEntry
|
||||||
{
|
{
|
||||||
public int RewardIndex { get; set; }
|
public int RewardIndex { get; set; }
|
||||||
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
public int MessageId { get; set; }
|
public int MessageId { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ public class BuildDeckSeriesRewardEntry
|
|||||||
{
|
{
|
||||||
public int TierIndex { get; set; } // 1, 2, 3, ... — unlock threshold
|
public int TierIndex { get; set; } // 1, 2, 3, ... — unlock threshold
|
||||||
public int ItemIndex { get; set; } // ordinal within tier
|
public int ItemIndex { get; set; } // ordinal within tier
|
||||||
public int RewardType { get; set; }
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
public int MessageId { get; set; }
|
public int MessageId { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ namespace SVSim.Database.Models;
|
|||||||
public class LeaderSkinShopProductRewardEntry
|
public class LeaderSkinShopProductRewardEntry
|
||||||
{
|
{
|
||||||
public int OrderIndex { get; set; }
|
public int OrderIndex { get; set; }
|
||||||
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ namespace SVSim.Database.Models;
|
|||||||
public class LeaderSkinShopSeriesRewardEntry
|
public class LeaderSkinShopSeriesRewardEntry
|
||||||
{
|
{
|
||||||
public int OrderIndex { get; set; }
|
public int OrderIndex { get; set; }
|
||||||
public int RewardType { get; set; }
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using SVSim.Database.Common;
|
using SVSim.Database.Common;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ public class MissionCatalogEntry : BaseEntity<int>
|
|||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public int LotType { get; set; }
|
public int LotType { get; set; }
|
||||||
public int RequireNumber { get; set; }
|
public int RequireNumber { get; set; }
|
||||||
public int RewardType { get; set; }
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
public int BattlePassPoint { get; set; }
|
public int BattlePassPoint { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using SVSim.Database.Common;
|
using SVSim.Database.Common;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ public class PuzzleMissionEntry : BaseEntity<int>
|
|||||||
public int OrderId { get; set; }
|
public int OrderId { get; set; }
|
||||||
|
|
||||||
// Reward (single-entry per mission)
|
// Reward (single-entry per mission)
|
||||||
public int RewardType { get; set; } // UserGoodsType
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
|
||||||
namespace SVSim.Database.Models;
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ namespace SVSim.Database.Models;
|
|||||||
public class SleeveShopProductRewardEntry
|
public class SleeveShopProductRewardEntry
|
||||||
{
|
{
|
||||||
public int OrderIndex { get; set; }
|
public int OrderIndex { get; set; }
|
||||||
public int RewardType { get; set; } // Wizard.UserGoods.Type
|
public UserGoodsType RewardType { get; set; }
|
||||||
public long RewardDetailId { get; set; }
|
public long RewardDetailId { get; set; }
|
||||||
public int RewardNumber { get; set; }
|
public int RewardNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace SVSim.Database.Services;
|
|||||||
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
|
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
|
||||||
/// collection grants — see <see cref="Models.RewardListEntry"/>.
|
/// collection grants — see <see cref="Models.RewardListEntry"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
|
public sealed record GrantedReward(UserGoodsType RewardType, long RewardId, int RewardNum);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
|
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
|||||||
var output = new List<GrantedReward>();
|
var output = new List<GrantedReward>();
|
||||||
foreach (var type in orderedTouches)
|
foreach (var type in orderedTouches)
|
||||||
{
|
{
|
||||||
output.Add(new GrantedReward((int)type, 0, lastCurrencyPost[type]));
|
output.Add(new GrantedReward(type, 0, lastCurrencyPost[type]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2 — non-currency grants: one entry per (type, id) using LAST post-state for items
|
// Pass 2 — non-currency grants: one entry per (type, id) using LAST post-state for items
|
||||||
@@ -326,7 +326,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
|||||||
}
|
}
|
||||||
foreach (var (type, id) in nonCurrencyOrder)
|
foreach (var (type, id) in nonCurrencyOrder)
|
||||||
{
|
{
|
||||||
output.Add(new GrantedReward((int)type, id, nonCurrencyKey[(type, id)]));
|
output.Add(new GrantedReward(type, id, nonCurrencyKey[(type, id)]));
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
|||||||
private IReadOnlyList<GrantedReward> BuildDeltas()
|
private IReadOnlyList<GrantedReward> BuildDeltas()
|
||||||
=> _ops.OfType<GrantOp>()
|
=> _ops.OfType<GrantOp>()
|
||||||
.Where(o => !o.IsCascade)
|
.Where(o => !o.IsCascade)
|
||||||
.Select(o => new GrantedReward((int)o.Type, o.DetailId, o.Num))
|
.Select(o => new GrantedReward(o.Type, o.DetailId, o.Num))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
private static bool IsCurrency(UserGoodsType t) =>
|
private static bool IsCurrency(UserGoodsType t) =>
|
||||||
@@ -353,7 +353,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
|
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
|
||||||
=> new[] { new GrantedReward((int)type, id, num) };
|
=> new[] { new GrantedReward(type, id, num) };
|
||||||
|
|
||||||
private void ThrowIfCommitted()
|
private void ThrowIfCommitted()
|
||||||
{
|
{
|
||||||
@@ -381,7 +381,7 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
|||||||
|
|
||||||
var results = new List<GrantedReward>
|
var results = new List<GrantedReward>
|
||||||
{
|
{
|
||||||
new((int)UserGoodsType.Card, cardId, postCount),
|
new(UserGoodsType.Card, cardId, postCount),
|
||||||
};
|
};
|
||||||
_ops.Add(new GrantOp(UserGoodsType.Card, cardId, num, postCount, false));
|
_ops.Add(new GrantOp(UserGoodsType.Card, cardId, num, postCount, false));
|
||||||
|
|
||||||
@@ -394,8 +394,8 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
|||||||
{
|
{
|
||||||
if (TryAddCascadeCosmetic(reward, lookupId))
|
if (TryAddCascadeCosmetic(reward, lookupId))
|
||||||
{
|
{
|
||||||
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
|
results.Add(new GrantedReward((UserGoodsType)reward.Type, reward.CosmeticId, 1));
|
||||||
_ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true));
|
_ops.Add(new GrantOp((UserGoodsType)reward.Type, reward.CosmeticId, 1, 1, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public class AchievementController : SVSimController
|
|||||||
await using var tx = await _inv.BeginAsync(viewerId, ct);
|
await using var tx = await _inv.BeginAsync(viewerId, ct);
|
||||||
|
|
||||||
var granted = await tx.GrantAsync(
|
var granted = await tx.GrantAsync(
|
||||||
(UserGoodsType)catalogRow.RewardType,
|
catalogRow.RewardType,
|
||||||
catalogRow.RewardDetailId,
|
catalogRow.RewardDetailId,
|
||||||
catalogRow.RewardNumber,
|
catalogRow.RewardNumber,
|
||||||
ct);
|
ct);
|
||||||
@@ -108,13 +108,13 @@ public class AchievementController : SVSimController
|
|||||||
MissionReceiveType = dto.MissionReceiveType,
|
MissionReceiveType = dto.MissionReceiveType,
|
||||||
RewardList = granted.Select(g => new RewardGrantDto
|
RewardList = granted.Select(g => new RewardGrantDto
|
||||||
{
|
{
|
||||||
RewardType = g.RewardType,
|
RewardType = (int)g.RewardType,
|
||||||
RewardId = g.RewardId,
|
RewardId = g.RewardId,
|
||||||
RewardNum = g.RewardNum,
|
RewardNum = g.RewardNum,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
TotalReceiveCountList = granted.Select(g => new TotalReceiveCountDto
|
TotalReceiveCountList = granted.Select(g => new TotalReceiveCountDto
|
||||||
{
|
{
|
||||||
RewardType = g.RewardType,
|
RewardType = (int)g.RewardType,
|
||||||
RewardDetailId = g.RewardId,
|
RewardDetailId = g.RewardId,
|
||||||
RewardCount = g.RewardNum,
|
RewardCount = g.RewardNum,
|
||||||
ItemType = 0,
|
ItemType = 0,
|
||||||
|
|||||||
@@ -27,20 +27,13 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
[HttpPost("do_matching")]
|
[HttpPost("do_matching")]
|
||||||
public async Task<IActionResult> DoMatching(
|
public async Task<IActionResult> DoMatching(
|
||||||
[FromBody] DoMatchingRequest req,
|
[FromBody] DoMatchingRequest req,
|
||||||
[FromQuery(Name = "scripted")] string? scripted = null,
|
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||||
// Accept "1" or "true" (case-insensitive) as per-request opt-in for the Scripted
|
|
||||||
// path. ASP.NET's default bool binder rejects "1", so parse permissively here.
|
|
||||||
// BattleNodeOptions.SoloDefaultsToScripted is the process-wide equivalent and is
|
|
||||||
// applied inside the resolver.
|
|
||||||
var scriptedOptIn = scripted is not null
|
|
||||||
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase));
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||||
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), scriptedOptIn, ct);
|
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), ct);
|
||||||
return Ok(new DoMatchingResponseDto
|
return Ok(new DoMatchingResponseDto
|
||||||
{
|
{
|
||||||
MatchingState = r.MatchingState,
|
MatchingState = r.MatchingState,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public class BattlePassController : SVSimController
|
|||||||
BattlePassRewardList = outcome.AchievedRewards
|
BattlePassRewardList = outcome.AchievedRewards
|
||||||
.Select(g => new BattlePassReceivedRewardDto
|
.Select(g => new BattlePassReceivedRewardDto
|
||||||
{
|
{
|
||||||
RewardType = g.RewardType,
|
RewardType = (int)g.RewardType,
|
||||||
RewardDetailId = g.RewardId,
|
RewardDetailId = g.RewardId,
|
||||||
RewardNumber = g.RewardNum,
|
RewardNumber = g.RewardNum,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
@@ -72,7 +72,7 @@ public class BattlePassController : SVSimController
|
|||||||
RewardList = outcome.PostStateTotals
|
RewardList = outcome.PostStateTotals
|
||||||
.Select(g => new BattlePassRewardListEntryDto
|
.Select(g => new BattlePassRewardListEntryDto
|
||||||
{
|
{
|
||||||
RewardType = g.RewardType,
|
RewardType = (int)g.RewardType,
|
||||||
RewardId = g.RewardId,
|
RewardId = g.RewardId,
|
||||||
RewardNum = g.RewardNum,
|
RewardNum = g.RewardNum,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public class BuildDeckController : SVSimController
|
|||||||
.OrderBy(r => r.RewardIndex)
|
.OrderBy(r => r.RewardIndex)
|
||||||
.Select(r => new BuildDeckProductRewardDto
|
.Select(r => new BuildDeckProductRewardDto
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType,
|
RewardType = (int)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
MessageId = r.MessageId,
|
MessageId = r.MessageId,
|
||||||
@@ -120,7 +120,7 @@ public class BuildDeckController : SVSimController
|
|||||||
IsGet = totalSeriesPurchases >= g.Key,
|
IsGet = totalSeriesPurchases >= g.Key,
|
||||||
RewardList = g.OrderBy(r => r.ItemIndex).Select(r => new BuildDeckProductRewardDto
|
RewardList = g.OrderBy(r => r.ItemIndex).Select(r => new BuildDeckProductRewardDto
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType,
|
RewardType = (int)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
MessageId = r.MessageId,
|
MessageId = r.MessageId,
|
||||||
@@ -206,7 +206,7 @@ public class BuildDeckController : SVSimController
|
|||||||
|
|
||||||
// Per-buy rewards
|
// Per-buy rewards
|
||||||
foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
|
foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||||
|
|
||||||
// Series-reward tier crossings
|
// Series-reward tier crossings
|
||||||
var crossedTiers = product.Series.SeriesRewards
|
var crossedTiers = product.Series.SeriesRewards
|
||||||
@@ -220,10 +220,10 @@ public class BuildDeckController : SVSimController
|
|||||||
{
|
{
|
||||||
foreach (var item in tier.OrderBy(r => r.ItemIndex))
|
foreach (var item in tier.OrderBy(r => r.ItemIndex))
|
||||||
{
|
{
|
||||||
await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber);
|
await tx.GrantAsync(item.RewardType, item.RewardDetailId, item.RewardNumber);
|
||||||
seriesRewards.Add(new BuildDeckProductRewardDto
|
seriesRewards.Add(new BuildDeckProductRewardDto
|
||||||
{
|
{
|
||||||
RewardType = item.RewardType,
|
RewardType = (int)item.RewardType,
|
||||||
RewardDetailId = item.RewardDetailId,
|
RewardDetailId = item.RewardDetailId,
|
||||||
RewardNumber = item.RewardNumber,
|
RewardNumber = item.RewardNumber,
|
||||||
MessageId = item.MessageId,
|
MessageId = item.MessageId,
|
||||||
@@ -235,9 +235,7 @@ public class BuildDeckController : SVSimController
|
|||||||
|
|
||||||
return new BuildDeckBuyResponse
|
return new BuildDeckBuyResponse
|
||||||
{
|
{
|
||||||
RewardList = result.RewardList
|
RewardList = result.RewardList.ToRewardList(),
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList(),
|
|
||||||
SeriesRewards = seriesRewards,
|
SeriesRewards = seriesRewards,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ public class CardController : SVSimController
|
|||||||
foreach (var (cardId, snapshot) in snapshots)
|
foreach (var (cardId, snapshot) in snapshots)
|
||||||
{
|
{
|
||||||
int requestedNum = createCounts[cardId];
|
int requestedNum = createCounts[cardId];
|
||||||
int postCount = grants.FirstOrDefault(g => g.RewardType == (int)UserGoodsType.Card && g.RewardId == cardId)?.RewardNum ?? 0;
|
int postCount = grants.FirstOrDefault(g => g.RewardType == UserGoodsType.Card && g.RewardId == cardId)?.RewardNum ?? 0;
|
||||||
int reconstructedPre = postCount - requestedNum;
|
int reconstructedPre = postCount - requestedNum;
|
||||||
if (reconstructedPre != snapshot)
|
if (reconstructedPre != snapshot)
|
||||||
{
|
{
|
||||||
@@ -114,7 +114,7 @@ public class CardController : SVSimController
|
|||||||
{
|
{
|
||||||
rewardList.Add(new RewardListEntry
|
rewardList.Add(new RewardListEntry
|
||||||
{
|
{
|
||||||
RewardType = grant.RewardType,
|
RewardType = (int)grant.RewardType,
|
||||||
RewardId = grant.RewardId,
|
RewardId = grant.RewardId,
|
||||||
RewardNum = grant.RewardNum,
|
RewardNum = grant.RewardNum,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,9 +145,7 @@ public class ItemPurchaseController : SVSimController
|
|||||||
|
|
||||||
return new ItemPurchasePurchaseResponse
|
return new ItemPurchasePurchaseResponse
|
||||||
{
|
{
|
||||||
RewardList = result.RewardList
|
RewardList = result.RewardList.ToRewardList(),
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ public class LeaderSkinController : SVSimController
|
|||||||
Status = rewardStatus,
|
Status = rewardStatus,
|
||||||
Items = s.SetCompletionRewards.OrderBy(r => r.OrderIndex).Select(r => new SkinSeriesRewardItemDto
|
Items = s.SetCompletionRewards.OrderBy(r => r.OrderIndex).Select(r => new SkinSeriesRewardItemDto
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType,
|
RewardType = (int)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
@@ -203,14 +203,12 @@ public class LeaderSkinController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||||
|
|
||||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||||
return new LeaderSkinBuyResponse
|
return new LeaderSkinBuyResponse
|
||||||
{
|
{
|
||||||
RewardList = result.RewardList
|
RewardList = result.RewardList.ToRewardList(),
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,15 +258,13 @@ public class LeaderSkinController : SVSimController
|
|||||||
foreach (var p in series.Products.OrderBy(p => p.Id))
|
foreach (var p in series.Products.OrderBy(p => p.Id))
|
||||||
{
|
{
|
||||||
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
|
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||||
return new LeaderSkinBuyResponse
|
return new LeaderSkinBuyResponse
|
||||||
{
|
{
|
||||||
RewardList = result.RewardList
|
RewardList = result.RewardList.ToRewardList(),
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +294,7 @@ public class LeaderSkinController : SVSimController
|
|||||||
return BadRequest(new { error = "series_not_completed" });
|
return BadRequest(new { error = "series_not_completed" });
|
||||||
|
|
||||||
foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
|
foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||||
|
|
||||||
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
|
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
|
||||||
{
|
{
|
||||||
@@ -310,9 +306,7 @@ public class LeaderSkinController : SVSimController
|
|||||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||||
return new LeaderSkinBuyResponse
|
return new LeaderSkinBuyResponse
|
||||||
{
|
{
|
||||||
RewardList = result.RewardList
|
RewardList = result.RewardList.ToRewardList(),
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +356,7 @@ public class LeaderSkinController : SVSimController
|
|||||||
},
|
},
|
||||||
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SkinProductRewardDto
|
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SkinProductRewardDto
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType,
|
RewardType = (int)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
IsOwned = IsRewardOwned(r, ownedSkinIds),
|
IsOwned = IsRewardOwned(r, ownedSkinIds),
|
||||||
@@ -379,7 +373,7 @@ public class LeaderSkinController : SVSimController
|
|||||||
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
|
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
|
||||||
{
|
{
|
||||||
// Skin reward: direct check.
|
// Skin reward: direct check.
|
||||||
if (r.RewardType == (int)UserGoodsType.Skin)
|
if (r.RewardType == UserGoodsType.Skin)
|
||||||
return ownedSkinIds.Contains((int)r.RewardDetailId);
|
return ownedSkinIds.Contains((int)r.RewardDetailId);
|
||||||
// Other types: we don't have the full cosmetic-owned graph in scope here. The product's
|
// Other types: we don't have the full cosmetic-owned graph in scope here. The product's
|
||||||
// sibling Skin reward tells us whether the bundle was purchased; piggy-back on that by
|
// sibling Skin reward tells us whether the bundle was purchased; piggy-back on that by
|
||||||
|
|||||||
@@ -418,9 +418,7 @@ public class PackController : SVSimController
|
|||||||
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
|
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
|
||||||
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
|
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
|
||||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||||
var rewardList = result.RewardList
|
var rewardList = result.RewardList.ToRewardList();
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new PackOpenResponse
|
return new PackOpenResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public class PuzzleController : SVSimController
|
|||||||
RewardList = new List<PuzzleMissionRewardResponse>
|
RewardList = new List<PuzzleMissionRewardResponse>
|
||||||
{
|
{
|
||||||
new() {
|
new() {
|
||||||
RewardType = s.Mission.RewardType,
|
RewardType = (int)s.Mission.RewardType,
|
||||||
RewardDetailId = s.Mission.RewardDetailId,
|
RewardDetailId = s.Mission.RewardDetailId,
|
||||||
RewardNumber = s.Mission.RewardNumber,
|
RewardNumber = s.Mission.RewardNumber,
|
||||||
},
|
},
|
||||||
@@ -182,7 +182,7 @@ public class PuzzleController : SVSimController
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
granted = await tx.GrantAsync(
|
granted = await tx.GrantAsync(
|
||||||
(UserGoodsType)status.Mission.RewardType,
|
status.Mission.RewardType,
|
||||||
status.Mission.RewardDetailId,
|
status.Mission.RewardDetailId,
|
||||||
status.Mission.RewardNumber);
|
status.Mission.RewardNumber);
|
||||||
}
|
}
|
||||||
@@ -200,7 +200,7 @@ public class PuzzleController : SVSimController
|
|||||||
});
|
});
|
||||||
response.AchievedInfo.AchievedMissionRewardList.Add(new PuzzleAchievedMissionReward
|
response.AchievedInfo.AchievedMissionRewardList.Add(new PuzzleAchievedMissionReward
|
||||||
{
|
{
|
||||||
MissionRewardType = status.Mission.RewardType,
|
MissionRewardType = (int)status.Mission.RewardType,
|
||||||
MissionRewardDetailId = status.Mission.RewardDetailId,
|
MissionRewardDetailId = status.Mission.RewardDetailId,
|
||||||
MissionRewardNumber = status.Mission.RewardNumber,
|
MissionRewardNumber = status.Mission.RewardNumber,
|
||||||
});
|
});
|
||||||
@@ -208,7 +208,7 @@ public class PuzzleController : SVSimController
|
|||||||
{
|
{
|
||||||
response.RewardList.Add(new TreasureRewardResponse
|
response.RewardList.Add(new TreasureRewardResponse
|
||||||
{
|
{
|
||||||
RewardType = g.RewardType,
|
RewardType = (int)g.RewardType,
|
||||||
RewardId = g.RewardId,
|
RewardId = g.RewardId,
|
||||||
RewardNum = g.RewardNum,
|
RewardNum = g.RewardNum,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,10 +132,7 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
|
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rank battle has no ?scripted=1 query opt-in (no live capture has shown such a
|
var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), ct);
|
||||||
// param on the rank URLs). The process-wide BattleNodeOptions.SoloDefaultsToScripted
|
|
||||||
// toggle is the only scripted entry point and is honored inside the resolver.
|
|
||||||
var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), scriptedOptIn: false, ct);
|
|
||||||
|
|
||||||
return Ok(new DoMatchingResponseDto
|
return Ok(new DoMatchingResponseDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public class SleeveController : SVSimController
|
|||||||
IsPurchasedProduct = IsProductPurchased(p, ownedSleeveIds),
|
IsPurchasedProduct = IsProductPurchased(p, ownedSleeveIds),
|
||||||
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SleeveProductRewardDto
|
Rewards = p.Rewards.OrderBy(r => r.OrderIndex).Select(r => new SleeveProductRewardDto
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType,
|
RewardType = (int)r.RewardType,
|
||||||
RewardDetailId = r.RewardDetailId,
|
RewardDetailId = r.RewardDetailId,
|
||||||
RewardNumber = r.RewardNumber,
|
RewardNumber = r.RewardNumber,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
@@ -142,15 +142,13 @@ public class SleeveController : SVSimController
|
|||||||
|
|
||||||
// Grant each catalog reward through the central dispatcher.
|
// Grant each catalog reward through the central dispatcher.
|
||||||
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||||
|
|
||||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||||
|
|
||||||
return new SleeveBuyResponse
|
return new SleeveBuyResponse
|
||||||
{
|
{
|
||||||
RewardList = result.RewardList
|
RewardList = result.RewardList.ToRewardList(),
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +162,7 @@ public class SleeveController : SVSimController
|
|||||||
{
|
{
|
||||||
foreach (var r in product.Rewards)
|
foreach (var r in product.Rewards)
|
||||||
{
|
{
|
||||||
if (r.RewardType == (int)UserGoodsType.Sleeve && ownedSleeveIds.Contains(r.RewardDetailId))
|
if (r.RewardType == UserGoodsType.Sleeve && ownedSleeveIds.Contains(r.RewardDetailId))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -143,15 +143,7 @@ public class SpotCardExchangeController : SVSimController
|
|||||||
|
|
||||||
// Grant the card itself via the inventory tx (handles cosmetic cascade).
|
// Grant the card itself via the inventory tx (handles cosmetic cascade).
|
||||||
var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1);
|
var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1);
|
||||||
foreach (var g in granted)
|
rewardList.AddRange(granted.ToRewardList());
|
||||||
{
|
|
||||||
rewardList.Add(new RewardListEntry
|
|
||||||
{
|
|
||||||
RewardType = g.RewardType,
|
|
||||||
RewardId = g.RewardId,
|
|
||||||
RewardNum = g.RewardNum,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_db.ViewerSpotCardExchanges.Add(new ViewerSpotCardExchange
|
_db.ViewerSpotCardExchanges.Add(new ViewerSpotCardExchange
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ namespace SVSim.EmulatedEntrypoint.Matching;
|
|||||||
/// regardless of which URL family carried the request:
|
/// regardless of which URL family carried the request:
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item>Honor the dev-affordance scripted opt-in (route flag and/or
|
/// <item>Consult <see cref="IMatchingPairUpService"/> and translate the
|
||||||
/// <see cref="BattleNodeOptions.SoloDefaultsToScripted"/>) — bypass pair-up,
|
|
||||||
/// register a Scripted match, return immediately.</item>
|
|
||||||
/// <item>Otherwise consult <see cref="IMatchingPairUpService"/> and translate the
|
|
||||||
/// resulting <see cref="PairUpResult"/> into a wire matching_state per the
|
/// resulting <see cref="PairUpResult"/> into a wire matching_state per the
|
||||||
/// 3002 / 3004 / 3007 / 3011 vocabulary.</item>
|
/// 3002 / 3004 / 3007 / 3011 vocabulary.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
@@ -33,15 +30,9 @@ public interface IMatchingResolver
|
|||||||
/// <c>"rotation_rank_battle"</c>, <c>"unlimited_rank_battle"</c>).
|
/// <c>"rotation_rank_battle"</c>, <c>"unlimited_rank_battle"</c>).
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="player">Caller's <see cref="BattlePlayer"/> (viewer-id + built MatchContext).</param>
|
/// <param name="player">Caller's <see cref="BattlePlayer"/> (viewer-id + built MatchContext).</param>
|
||||||
/// <param name="scriptedOptIn">
|
|
||||||
/// Per-request opt-in from a controller-specific signal (e.g. TK2's <c>?scripted=1</c>
|
|
||||||
/// query param). OR'd with <see cref="BattleNodeOptions.SoloDefaultsToScripted"/>;
|
|
||||||
/// either being true short-circuits to a Scripted match.
|
|
||||||
/// </param>
|
|
||||||
Task<MatchingResolution> ResolveAsync(
|
Task<MatchingResolution> ResolveAsync(
|
||||||
string mode,
|
string mode,
|
||||||
BattlePlayer player,
|
BattlePlayer player,
|
||||||
bool scriptedOptIn,
|
|
||||||
CancellationToken ct);
|
CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using SVSim.BattleNode.Bridge;
|
using SVSim.BattleNode.Bridge;
|
||||||
using SVSim.BattleNode.Sessions;
|
|
||||||
|
|
||||||
namespace SVSim.EmulatedEntrypoint.Matching;
|
namespace SVSim.EmulatedEntrypoint.Matching;
|
||||||
|
|
||||||
@@ -8,34 +7,20 @@ public sealed class MatchingResolver : IMatchingResolver
|
|||||||
{
|
{
|
||||||
private readonly IMatchingBridge _bridge;
|
private readonly IMatchingBridge _bridge;
|
||||||
private readonly IMatchingPairUpService _pairUp;
|
private readonly IMatchingPairUpService _pairUp;
|
||||||
private readonly BattleNodeOptions _options;
|
|
||||||
|
|
||||||
public MatchingResolver(
|
public MatchingResolver(
|
||||||
IMatchingBridge bridge,
|
IMatchingBridge bridge,
|
||||||
IMatchingPairUpService pairUp,
|
IMatchingPairUpService pairUp)
|
||||||
BattleNodeOptions options)
|
|
||||||
{
|
{
|
||||||
_bridge = bridge;
|
_bridge = bridge;
|
||||||
_pairUp = pairUp;
|
_pairUp = pairUp;
|
||||||
_options = options;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MatchingResolution> ResolveAsync(
|
public Task<MatchingResolution> ResolveAsync(
|
||||||
string mode,
|
string mode,
|
||||||
BattlePlayer player,
|
BattlePlayer player,
|
||||||
bool scriptedOptIn,
|
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Dev-affordance short-circuit. Either a per-request flag (e.g. ?scripted=1) or the
|
|
||||||
// process-wide BattleNodeOptions.SoloDefaultsToScripted toggle puts us here.
|
|
||||||
// Registers a Scripted match (server-side scripted opponent in BattleSession) and
|
|
||||||
// returns matching_state=3004 SUCCEEDED so the client opens the WS and proceeds.
|
|
||||||
if (scriptedOptIn || _options.SoloDefaultsToScripted)
|
|
||||||
{
|
|
||||||
var m = _bridge.RegisterBattle(player, p2: null, BattleType.Scripted);
|
|
||||||
return Task.FromResult(new MatchingResolution(3004, m.BattleId, m.NodeServerUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResolveViaPairUpAsync(mode, player, ct);
|
return ResolveViaPairUpAsync(mode, player, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListExtensions.cs
Normal file
21
SVSim.EmulatedEntrypoint/Models/Dtos/RewardListExtensions.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using SVSim.Database.Services;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects inventory <see cref="GrantedReward"/> results (post-state totals or per-grant deltas)
|
||||||
|
/// into the wire <see cref="RewardListEntry"/> shape. The <c>reward_type</c> enum is widened to its
|
||||||
|
/// int wire value at this single boundary. Replaces the per-endpoint copies of this projection
|
||||||
|
/// (pack/open, leader_skin/buy*, build_deck/buy, sleeve/buy, item_purchase, spot_card_exchange,
|
||||||
|
/// gacha-point exchange).
|
||||||
|
/// </summary>
|
||||||
|
public static class RewardListExtensions
|
||||||
|
{
|
||||||
|
public static List<RewardListEntry> ToRewardList(this IEnumerable<GrantedReward> grants) =>
|
||||||
|
grants.Select(g => new RewardListEntry
|
||||||
|
{
|
||||||
|
RewardType = (int)g.RewardType,
|
||||||
|
RewardId = g.RewardId,
|
||||||
|
RewardNum = g.RewardNum,
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ public class Program
|
|||||||
// BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL.
|
// BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL.
|
||||||
opt.NodeServerUrl = "localhost:5148/socket.io/";
|
opt.NodeServerUrl = "localhost:5148/socket.io/";
|
||||||
// Any field in BattleNodeOptions can be overridden via the "BattleNode" section
|
// Any field in BattleNodeOptions can be overridden via the "BattleNode" section
|
||||||
// in appsettings*.json — see appsettings.Development.json for SoloDefaultsToScripted.
|
// in appsettings*.json — see appsettings.Development.json for DiagnosticLogging.
|
||||||
builder.Configuration.GetSection("BattleNode").Bind(opt);
|
builder.Configuration.GetSection("BattleNode").Bind(opt);
|
||||||
});
|
});
|
||||||
// In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback
|
// In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback
|
||||||
@@ -138,9 +138,8 @@ public class Program
|
|||||||
new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback),
|
new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback),
|
||||||
}));
|
}));
|
||||||
builder.Services.AddSingleton<IMatchingPairUpService, InProcessPairUp>();
|
builder.Services.AddSingleton<IMatchingPairUpService, InProcessPairUp>();
|
||||||
// Single resolver shared by every /do_matching family controller. Owns the scripted-
|
// Single resolver shared by every /do_matching family controller. Owns the
|
||||||
// flag short-circuit + the pair-up → matching_state mapping. Singleton: stateless,
|
// pair-up → matching_state mapping. Singleton: stateless, all deps are singletons too.
|
||||||
// all deps are singletons too.
|
|
||||||
builder.Services.AddSingleton<IMatchingResolver, MatchingResolver>();
|
builder.Services.AddSingleton<IMatchingResolver, MatchingResolver>();
|
||||||
// Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info.
|
// Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info.
|
||||||
// Transient because BotRoster depends on the transient IGlobalsRepository.
|
// Transient because BotRoster depends on the transient IGlobalsRepository.
|
||||||
@@ -149,7 +148,18 @@ public class Program
|
|||||||
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
|
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
|
||||||
builder.Services.AddTransient<SessionidMappingMiddleware>();
|
builder.Services.AddTransient<SessionidMappingMiddleware>();
|
||||||
builder.Services.AddSingleton<ShadowverseSessionService>();
|
builder.Services.AddSingleton<ShadowverseSessionService>();
|
||||||
builder.Services.AddSingleton<ISteamServer, FacepunchSteamServer>();
|
// Steam ticket validation seam. Production uses Facepunch against real Steam. Local dev
|
||||||
|
// can opt into a no-op validator via Auth:BypassSteamTicket so clients without a real
|
||||||
|
// Steam session (e.g. a second same-machine instance for the two-client PvP smoke) can
|
||||||
|
// authenticate. Gate is config-only and ships false everywhere except Development.
|
||||||
|
if (builder.Configuration.GetValue<bool>("Auth:BypassSteamTicket"))
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<ISteamServer, DevAlwaysValidSteamServer>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<ISteamServer, FacepunchSteamServer>();
|
||||||
|
}
|
||||||
builder.Services.AddSingleton<SteamSessionService>();
|
builder.Services.AddSingleton<SteamSessionService>();
|
||||||
builder.Services.AddAuthentication()
|
builder.Services.AddAuthentication()
|
||||||
.AddScheme<SteamAuthenticationHandlerOptions, SteamSessionAuthenticationHandler>(
|
.AddScheme<SteamAuthenticationHandlerOptions, SteamSessionAuthenticationHandler>(
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
|||||||
// Pre-load item_type for any Item-typed reward so we can populate it on the
|
// Pre-load item_type for any Item-typed reward so we can populate it on the
|
||||||
// per-grant delta entries. Currencies don't need a lookup (item_type stays 0).
|
// per-grant delta entries. Currencies don't need a lookup (item_type stays 0).
|
||||||
var itemRewardIds = rewardRows
|
var itemRewardIds = rewardRows
|
||||||
.Where(r => r.RewardType == (int)UserGoodsType.Item)
|
.Where(r => r.RewardType == UserGoodsType.Item)
|
||||||
.Select(r => (int)r.RewardId)
|
.Select(r => (int)r.RewardId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -325,10 +325,10 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
|||||||
// Skip when the rolled outcome is "nothing" (RewardNum == 0).
|
// Skip when the rolled outcome is "nothing" (RewardNum == 0).
|
||||||
if (pick.RewardNum <= 0) continue;
|
if (pick.RewardNum <= 0) continue;
|
||||||
|
|
||||||
await tx.GrantAsync((UserGoodsType)pick.RewardType, pick.RewardId, pick.RewardNum);
|
await tx.GrantAsync(pick.RewardType, pick.RewardId, pick.RewardNum);
|
||||||
deltas.Add(new TwoPickRewardReceivedDto
|
deltas.Add(new TwoPickRewardReceivedDto
|
||||||
{
|
{
|
||||||
RewardType = pick.RewardType,
|
RewardType = (int)pick.RewardType,
|
||||||
RewardDetailId = pick.RewardId,
|
RewardDetailId = pick.RewardId,
|
||||||
RewardCount = pick.RewardNum,
|
RewardCount = pick.RewardNum,
|
||||||
ItemType = itemTypeById.TryGetValue((int)pick.RewardId, out var t) ? t : 0,
|
ItemType = itemTypeById.TryGetValue((int)pick.RewardId, out var t) ? t : 0,
|
||||||
@@ -339,7 +339,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
|||||||
var result = await tx.CommitAsync();
|
var result = await tx.CommitAsync();
|
||||||
|
|
||||||
var postStates = result.RewardList
|
var postStates = result.RewardList
|
||||||
.Select(g => new RewardEntryDto { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
.Select(g => new RewardEntryDto { RewardType = (int)g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
await _runs.DeleteAsync(viewerId);
|
await _runs.DeleteAsync(viewerId);
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
{
|
{
|
||||||
if (claimSet.Contains((r.Track, r.Level))) continue;
|
if (claimSet.Contains((r.Track, r.Level))) continue;
|
||||||
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitAsync handles DB save + currency-collision rule. Crystal spend is the first
|
// CommitAsync handles DB save + currency-collision rule. Crystal spend is the first
|
||||||
@@ -240,7 +240,7 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue;
|
if (r.Track == BattlePassTrack.Premium && !progress.IsPremium) continue;
|
||||||
if (claimSet.Contains((r.Track, r.Level))) continue;
|
if (claimSet.Contains((r.Track, r.Level))) continue;
|
||||||
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ public sealed class BattlePassService : IBattlePassService
|
|||||||
return new BattlePassRewardDto
|
return new BattlePassRewardDto
|
||||||
{
|
{
|
||||||
RewardLevel = Inv(r.Level),
|
RewardLevel = Inv(r.Level),
|
||||||
RewardType = Inv(r.RewardType),
|
RewardType = Inv((int)r.RewardType),
|
||||||
RewardDetailId = Inv(r.RewardDetailId),
|
RewardDetailId = Inv(r.RewardDetailId),
|
||||||
RewardNumber = Inv(r.RewardNumber),
|
RewardNumber = Inv(r.RewardNumber),
|
||||||
IsReceived = claimSet.Contains((r.Track, r.Level)),
|
IsReceived = claimSet.Contains((r.Track, r.Level)),
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Development-only <see cref="ISteamServer"/> that accepts every ticket without contacting
|
||||||
|
/// Steam. Selected in <c>Program.cs</c> when <c>Auth:BypassSteamTicket</c> is true, so clients
|
||||||
|
/// with a synthetic (non-Steam) identity — e.g. a second instance on the same machine for the
|
||||||
|
/// two-client PvP smoke — can authenticate. NEVER select this outside local dev: it turns the
|
||||||
|
/// Steam ticket gate into a no-op for the whole process.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DevAlwaysValidSteamServer : ISteamServer
|
||||||
|
{
|
||||||
|
private readonly ILogger<DevAlwaysValidSteamServer> _logger;
|
||||||
|
|
||||||
|
public DevAlwaysValidSteamServer(ILogger<DevAlwaysValidSteamServer> logger) => _logger = logger;
|
||||||
|
|
||||||
|
public void Initialize(int appId) { }
|
||||||
|
|
||||||
|
public bool BeginAuthSession(byte[] ticket, ulong steamId)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"DEV Steam bypass: accepting ticket for steamId {SteamId} WITHOUT validation (ticketLen={Len}).",
|
||||||
|
steamId, ticket.Length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndSession(ulong steamId) { }
|
||||||
|
|
||||||
|
public void Shutdown() { }
|
||||||
|
}
|
||||||
@@ -210,9 +210,7 @@ public sealed class GachaPointService : IGachaPointService
|
|||||||
// Emblem (standard legendary) or Skin+Emblem (leader). Convert at the wire boundary
|
// Emblem (standard legendary) or Skin+Emblem (leader). Convert at the wire boundary
|
||||||
// so ExchangeOutcome still carries RewardListEntry for the controller response.
|
// so ExchangeOutcome still carries RewardListEntry for the controller response.
|
||||||
var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1);
|
var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, 1);
|
||||||
var rewardList = granted
|
var rewardList = granted.ToRewardList();
|
||||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return ExchangeOutcome.Ok(rewardList);
|
return ExchangeOutcome.Ok(rewardList);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
|||||||
LotType = cat.LotType.ToString(),
|
LotType = cat.LotType.ToString(),
|
||||||
BattlePassPoint = cat.BattlePassPoint.ToString(),
|
BattlePassPoint = cat.BattlePassPoint.ToString(),
|
||||||
RequireNumber = cat.RequireNumber,
|
RequireNumber = cat.RequireNumber,
|
||||||
RewardType = cat.RewardType,
|
RewardType = (int)cat.RewardType,
|
||||||
RewardDetailId = cat.RewardDetailId,
|
RewardDetailId = cat.RewardDetailId,
|
||||||
RewardNumber = cat.RewardNumber,
|
RewardNumber = cat.RewardNumber,
|
||||||
DefaultFlag = cat.DefaultFlag,
|
DefaultFlag = cat.DefaultFlag,
|
||||||
@@ -117,7 +117,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
|||||||
TotalCount = total,
|
TotalCount = total,
|
||||||
AchievementName = catalog.Name,
|
AchievementName = catalog.Name,
|
||||||
RequireNumber = catalog.RequireNumber,
|
RequireNumber = catalog.RequireNumber,
|
||||||
RewardType = catalog.RewardType,
|
RewardType = (int)catalog.RewardType,
|
||||||
RewardDetailId = catalog.RewardDetailId,
|
RewardDetailId = catalog.RewardDetailId,
|
||||||
RewardNumber = catalog.RewardNumber,
|
RewardNumber = catalog.RewardNumber,
|
||||||
MaxLevel = maxLevel,
|
MaxLevel = maxLevel,
|
||||||
@@ -164,7 +164,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
|||||||
{
|
{
|
||||||
entry.RewardInfo = new BPMonthlyMissionRewardInfoDto
|
entry.RewardInfo = new BPMonthlyMissionRewardInfoDto
|
||||||
{
|
{
|
||||||
RewardType = mm.RewardType.Value.ToString(),
|
RewardType = ((int)mm.RewardType.Value).ToString(),
|
||||||
RewardDetailId = (mm.RewardDetailId ?? 0).ToString(),
|
RewardDetailId = (mm.RewardDetailId ?? 0).ToString(),
|
||||||
RewardNumber = (mm.RewardNumber ?? 0).ToString(),
|
RewardNumber = (mm.RewardNumber ?? 0).ToString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ public class StoryService : IStoryService
|
|||||||
}).ToList(),
|
}).ToList(),
|
||||||
StoryReward = c.Rewards.Select(r => new RewardDto
|
StoryReward = c.Rewards.Select(r => new RewardDto
|
||||||
{
|
{
|
||||||
RewardType = r.RewardType.ToString(),
|
RewardType = ((int)r.RewardType).ToString(),
|
||||||
RewardDetailId = r.RewardDetailId.ToString(),
|
RewardDetailId = r.RewardDetailId.ToString(),
|
||||||
RewardNumber = r.RewardNumber.ToString(),
|
RewardNumber = r.RewardNumber.ToString(),
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
@@ -539,7 +539,7 @@ public class StoryService : IStoryService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
await tx.GrantAsync(r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||||
}
|
}
|
||||||
catch (NotSupportedException ex)
|
catch (NotSupportedException ex)
|
||||||
{
|
{
|
||||||
@@ -564,7 +564,7 @@ public class StoryService : IStoryService
|
|||||||
{
|
{
|
||||||
resp.RewardList.Add(new RewardGrant
|
resp.RewardList.Add(new RewardGrant
|
||||||
{
|
{
|
||||||
RewardType = g.RewardType.ToString(),
|
RewardType = ((int)g.RewardType).ToString(),
|
||||||
RewardId = g.RewardId.ToString(),
|
RewardId = g.RewardId.ToString(),
|
||||||
RewardNum = g.RewardNum.ToString(),
|
RewardNum = g.RewardNum.ToString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Auth": {
|
||||||
|
"BypassSteamTicket": true
|
||||||
|
},
|
||||||
"BattleNode": {
|
"BattleNode": {
|
||||||
"SoloDefaultsToScripted": false,
|
"DiagnosticLogging": true
|
||||||
"DiagnosticLogging": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ namespace SVSim.UnitTests.BattleNode.Bridge;
|
|||||||
public class MatchingBridgeTests
|
public class MatchingBridgeTests
|
||||||
{
|
{
|
||||||
[Test]
|
[Test]
|
||||||
public void RegisterBattle_Scripted_stores_pending_and_returns_node_url()
|
public void RegisterBattle_Bot_stores_pending_and_returns_node_url()
|
||||||
{
|
{
|
||||||
var store = new InMemoryBattleSessionStore();
|
var store = new InMemoryBattleSessionStore();
|
||||||
var bridge = new MatchingBridge(store, new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" });
|
var bridge = new MatchingBridge(store, new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" });
|
||||||
var p1 = new BattlePlayer(906243102, FixtureCtx());
|
var p1 = new BattlePlayer(906243102, FixtureCtx());
|
||||||
|
|
||||||
var match = bridge.RegisterBattle(p1, p2: null, BattleType.Scripted);
|
var match = bridge.RegisterBattle(p1, p2: null, BattleType.Bot);
|
||||||
|
|
||||||
Assert.That(match.NodeServerUrl, Is.EqualTo("localhost:5148/socket.io/"));
|
Assert.That(match.NodeServerUrl, Is.EqualTo("localhost:5148/socket.io/"));
|
||||||
Assert.That(match.BattleId, Is.Not.Empty);
|
Assert.That(match.BattleId, Is.Not.Empty);
|
||||||
var pending = store.TryGetPending(match.BattleId);
|
var pending = store.TryGetPending(match.BattleId);
|
||||||
Assert.That(pending, Is.Not.Null);
|
Assert.That(pending, Is.Not.Null);
|
||||||
Assert.That(pending!.Type, Is.EqualTo(BattleType.Scripted));
|
Assert.That(pending!.Type, Is.EqualTo(BattleType.Bot));
|
||||||
Assert.That(pending.P1.ViewerId, Is.EqualTo(906243102));
|
Assert.That(pending.P1.ViewerId, Is.EqualTo(906243102));
|
||||||
Assert.That(pending.P2, Is.Null);
|
Assert.That(pending.P2, Is.Null);
|
||||||
}
|
}
|
||||||
@@ -30,8 +30,8 @@ public class MatchingBridgeTests
|
|||||||
{
|
{
|
||||||
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
||||||
|
|
||||||
var a = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Scripted);
|
var a = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Bot);
|
||||||
var b = bridge.RegisterBattle(new BattlePlayer(2, FixtureCtx()), null, BattleType.Scripted);
|
var b = bridge.RegisterBattle(new BattlePlayer(2, FixtureCtx()), null, BattleType.Bot);
|
||||||
|
|
||||||
Assert.That(a.BattleId, Is.Not.EqualTo(b.BattleId));
|
Assert.That(a.BattleId, Is.Not.EqualTo(b.BattleId));
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ public class MatchingBridgeTests
|
|||||||
{
|
{
|
||||||
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
var bridge = new MatchingBridge(new InMemoryBattleSessionStore(), new BattleNodeOptions());
|
||||||
|
|
||||||
var match = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Scripted);
|
var match = bridge.RegisterBattle(new BattlePlayer(1, FixtureCtx()), null, BattleType.Bot);
|
||||||
|
|
||||||
Assert.That(match.BattleId, Has.Length.EqualTo(12));
|
Assert.That(match.BattleId, Has.Length.EqualTo(12));
|
||||||
Assert.That(match.BattleId, Does.Match("^[0-9]{12}$"));
|
Assert.That(match.BattleId, Does.Match("^[0-9]{12}$"));
|
||||||
|
|||||||
@@ -15,73 +15,6 @@ namespace SVSim.UnitTests.BattleNode.Integration;
|
|||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class BattleNodeFlowTests
|
public class BattleNodeFlowTests
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// End-to-end smoke for the v1.2 scripted lifecycle. Boots the EmulatedEntrypoint via
|
|
||||||
/// SVSimTestFactory, mints a battle through IMatchingBridge with a fixture MatchContext,
|
|
||||||
/// opens a raw Socket.IO v2 client against the in-process TestServer, and drives
|
|
||||||
/// InitNetwork → Loaded → Swap → TurnEnd × 2, asserting the right scripted frames come
|
|
||||||
/// back in order including the two-cycle three-frame opponent-turn loop (TurnStart +
|
|
||||||
/// TurnEnd + Judge per cycle).
|
|
||||||
/// </summary>
|
|
||||||
[Test]
|
|
||||||
[Timeout(30000)]
|
|
||||||
public async Task ClientWalksHandshakeToReady_ReceivesAllScriptedFrames()
|
|
||||||
{
|
|
||||||
await using var factory = new SVSimTestFactory();
|
|
||||||
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
|
||||||
var ct = cts.Token;
|
|
||||||
var pending = bridge.RegisterBattle(
|
|
||||||
new SVSim.BattleNode.Bridge.BattlePlayer(906243102, FixtureCtx()),
|
|
||||||
p2: null,
|
|
||||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
|
||||||
|
|
||||||
var key = MakeKey();
|
|
||||||
var encryptedVid = NodeCrypto.EncryptForNode("906243102", key);
|
|
||||||
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
|
||||||
|
|
||||||
var wsClient = factory.Server.CreateWebSocketClient();
|
|
||||||
var ws = await wsClient.ConnectAsync(wsUri, ct);
|
|
||||||
await using var client = new RawSocketIoTestClient(ws);
|
|
||||||
await client.ConsumeHandshakeAsync(ct);
|
|
||||||
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
|
||||||
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.InitBattle, pubSeq: 2), key, ct);
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
|
||||||
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Loaded, pubSeq: 3), key, ct);
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.BattleStart));
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Deal));
|
|
||||||
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.Swap, pubSeq: 4,
|
|
||||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Swap));
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Ready));
|
|
||||||
|
|
||||||
// --- v1.2 opponent turn loop: drive two consecutive cycles ---
|
|
||||||
// Cycle 1: player ends turn -> server pushes opponent TurnStart + TurnEnd + Judge.
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
|
||||||
|
|
||||||
// Cycle 2: same burst again -- session phase reset to AfterReady, so the next TurnEnd matches.
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(NetworkBattleUri.TurnEnd, pubSeq: 6), key, ct);
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
|
||||||
Assert.That((await client.ReceiveSynchronizeAsync(ct)).Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, Dictionary<string, object?>? body = null) =>
|
|
||||||
new(uri, ViewerId: 906243102, Uuid: "udid-test", Bid: null, Try: 0,
|
|
||||||
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
|
||||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
|
||||||
: EmitCategory.Battle,
|
|
||||||
PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary<string, object?>()));
|
|
||||||
|
|
||||||
private static string MakeKey()
|
private static string MakeKey()
|
||||||
{
|
{
|
||||||
var seq = 0;
|
var seq = 0;
|
||||||
@@ -101,7 +34,7 @@ public class BattleNodeFlowTests
|
|||||||
/// against an actual seeded viewer.
|
/// against an actual seeded viewer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Test]
|
[Test]
|
||||||
[Timeout(30000)]
|
[Timeout(60000)]
|
||||||
public async Task Matched_frame_contains_drafted_deck_cards()
|
public async Task Matched_frame_contains_drafted_deck_cards()
|
||||||
{
|
{
|
||||||
await using var factory = new SVSimTestFactory();
|
await using var factory = new SVSimTestFactory();
|
||||||
@@ -133,21 +66,23 @@ public class BattleNodeFlowTests
|
|||||||
var ctx = await builder.BuildForTwoPickAsync(vid);
|
var ctx = await builder.BuildForTwoPickAsync(vid);
|
||||||
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||||
var ct = cts.Token;
|
var ct = cts.Token;
|
||||||
|
var vidB = vid + 1;
|
||||||
var pending = bridge.RegisterBattle(
|
var pending = bridge.RegisterBattle(
|
||||||
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
||||||
p2: null,
|
new SVSim.BattleNode.Bridge.BattlePlayer(vidB, FixtureCtx()),
|
||||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
SVSim.BattleNode.Sessions.BattleType.Pvp);
|
||||||
|
|
||||||
var key = MakeKey();
|
var key = MakeKey();
|
||||||
var encryptedVid = NodeCrypto.EncryptForNode(vid.ToString(), key);
|
// PvP constructs the BattleSession on the SECOND arriver, so connecting only P1 parks it
|
||||||
var wsUri = new Uri($"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
// forever. Connect BOTH clients, then drive P1 (the seeded viewer) through
|
||||||
|
// InitNetwork/InitBattle to harvest its own Matched — pushed to the sender before the
|
||||||
var wsClient = factory.Server.CreateWebSocketClient();
|
// mulligan barrier, so B's handshake is not needed for P1's Matched to arrive.
|
||||||
var ws = await wsClient.ConnectAsync(wsUri, ct);
|
var (client, clientB) = await ConnectBothAsync(factory, pending.BattleId, vid, vidB, key, ct);
|
||||||
await using var client = new RawSocketIoTestClient(ws);
|
await using var _a = client;
|
||||||
await client.ConsumeHandshakeAsync(ct);
|
await using var _b = clientB;
|
||||||
|
await Task.WhenAll(client.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||||
|
|
||||||
// InitNetwork → ack
|
// InitNetwork → ack
|
||||||
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
|
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.InitNetwork, pubSeq: 1), key, ct);
|
||||||
@@ -218,24 +153,30 @@ public class BattleNodeFlowTests
|
|||||||
await using var _b = clientB;
|
await using var _b = clientB;
|
||||||
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||||
|
|
||||||
await DriveHandshakeAsync(clientA, vidA, key, ct);
|
await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct);
|
||||||
await DriveHandshakeAsync(clientB, vidB, key, ct);
|
|
||||||
|
|
||||||
// Both are now AfterReady. A sends TurnEnd; both should receive TurnEnd + Judge.
|
// Both are now AfterReady. Deterministic-turn handover, mirroring the real two-client
|
||||||
|
// capture (2026-06-03 battle_test). A ends its turn; the OPPONENT (B) receives the
|
||||||
|
// translated {turnState:0} TurnEnd. A receives nothing — it already ran the turn locally.
|
||||||
await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
|
await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
|
||||||
|
var bTurnEnd = await clientB.ReceiveSynchronizeAsync(ct);
|
||||||
|
Assert.That(bTurnEnd.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||||
|
|
||||||
var aFirst = await clientA.ReceiveSynchronizeAsync(ct);
|
// The client rule is: receive opponent TurnEnd -> SendJudge. So B (the taker-over) sends
|
||||||
var aSecond = await clientA.ReceiveSynchronizeAsync(ct);
|
// Judge. The {spin:0} reflects BACK to B (its own ControlTurnStartPlayer gate), NOT to A —
|
||||||
var bFirst = await clientB.ReceiveSynchronizeAsync(ct);
|
// routing it to A would restart A's turn and stall the loop (the live-run bug this fixes).
|
||||||
var bSecond = await clientB.ReceiveSynchronizeAsync(ct);
|
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.Judge, pubSeq: 5), key, ct);
|
||||||
|
var bJudge = await clientB.ReceiveSynchronizeAsync(ct);
|
||||||
|
Assert.That(bJudge.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||||
|
|
||||||
Assert.That(new[] { aFirst.Uri, aSecond.Uri },
|
// B opens its turn: TurnStart relays to the opponent A as {spin:0} ("opponent's turn").
|
||||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.TurnStart, pubSeq: 6), key, ct);
|
||||||
Assert.That(new[] { bFirst.Uri, bSecond.Uri },
|
var aTurnStart = await clientA.ReceiveSynchronizeAsync(ct);
|
||||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
Assert.That(aTurnStart.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||||
|
|
||||||
// PlayActions forwarding: B sends, A receives.
|
// PlayActions translation: B plays a card; A receives the opponent-facing PlayActions
|
||||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 6), key, ct);
|
// frame (Uri preserved, body synthesized by PlayActionsHandler).
|
||||||
|
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 7), key, ct);
|
||||||
var aForwarded = await clientA.ReceiveSynchronizeAsync(ct);
|
var aForwarded = await clientA.ReceiveSynchronizeAsync(ct);
|
||||||
Assert.That(aForwarded.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
Assert.That(aForwarded.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
||||||
}
|
}
|
||||||
@@ -268,8 +209,7 @@ public class BattleNodeFlowTests
|
|||||||
await using var _a = clientA;
|
await using var _a = clientA;
|
||||||
await using var _b = clientB;
|
await using var _b = clientB;
|
||||||
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||||
await DriveHandshakeAsync(clientA, vidA, key, ct);
|
await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct);
|
||||||
await DriveHandshakeAsync(clientB, vidB, key, ct);
|
|
||||||
|
|
||||||
// A retires.
|
// A retires.
|
||||||
await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.Retire, pubSeq: 5), key, ct);
|
await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.Retire, pubSeq: 5), key, ct);
|
||||||
@@ -314,8 +254,7 @@ public class BattleNodeFlowTests
|
|||||||
var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct);
|
var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct);
|
||||||
await using var _b = clientB;
|
await using var _b = clientB;
|
||||||
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||||
await DriveHandshakeAsync(clientA, vidA, key, ct);
|
await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct);
|
||||||
await DriveHandshakeAsync(clientB, vidB, key, ct);
|
|
||||||
|
|
||||||
// Abruptly close A's WS (no Retire).
|
// Abruptly close A's WS (no Retire).
|
||||||
await clientA.DisposeAsync();
|
await clientA.DisposeAsync();
|
||||||
@@ -492,7 +431,10 @@ public class BattleNodeFlowTests
|
|||||||
|
|
||||||
// -- helpers -------------------------------------------------------------
|
// -- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
private static async Task DriveHandshakeAsync(
|
/// <summary>Drives one PvP client from InitNetwork through Swap, stopping at the
|
||||||
|
/// SwapResponse. Ready is NOT received here — the mulligan barrier withholds it until
|
||||||
|
/// BOTH sides have swapped, so the caller drains it after driving both sides.</summary>
|
||||||
|
private static async Task DriveThroughSwapAsync(
|
||||||
RawSocketIoTestClient client, long vid, string key, CancellationToken ct)
|
RawSocketIoTestClient client, long vid, string key, CancellationToken ct)
|
||||||
{
|
{
|
||||||
long pubSeq = 1;
|
long pubSeq = 1;
|
||||||
@@ -507,7 +449,23 @@ public class BattleNodeFlowTests
|
|||||||
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Swap, pubSeq++,
|
await client.SendMsgAsync(MakeEnvelopeWith(vid, NetworkBattleUri.Swap, pubSeq++,
|
||||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||||
await client.ReceiveSynchronizeAsync(ct); // Swap response
|
await client.ReceiveSynchronizeAsync(ct); // Swap response
|
||||||
await client.ReceiveSynchronizeAsync(ct); // Ready
|
}
|
||||||
|
|
||||||
|
/// <summary>Drives both PvP clients through the full handshake including the mulligan
|
||||||
|
/// barrier: each side swaps first (Ready withheld), then the second swap releases Ready
|
||||||
|
/// to both. Leaves both at AfterReady with pubSeq up to 4 consumed per client.</summary>
|
||||||
|
private static async Task DrivePvpHandshakeAsync(
|
||||||
|
RawSocketIoTestClient clientA, long vidA,
|
||||||
|
RawSocketIoTestClient clientB, long vidB, string key, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await DriveThroughSwapAsync(clientA, vidA, key, ct);
|
||||||
|
await DriveThroughSwapAsync(clientB, vidB, key, ct);
|
||||||
|
|
||||||
|
// B's Swap (the second) releases Ready to both sides.
|
||||||
|
var aReady = await clientA.ReceiveSynchronizeAsync(ct);
|
||||||
|
Assert.That(aReady.Uri, Is.EqualTo(NetworkBattleUri.Ready));
|
||||||
|
var bReady = await clientB.ReceiveSynchronizeAsync(ct);
|
||||||
|
Assert.That(bReady.Uri, Is.EqualTo(NetworkBattleUri.Ready));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(RawSocketIoTestClient, RawSocketIoTestClient)> ConnectBothAsync(
|
private static async Task<(RawSocketIoTestClient, RawSocketIoTestClient)> ConnectBothAsync(
|
||||||
|
|||||||
@@ -31,19 +31,18 @@ namespace SVSim.UnitTests.BattleNode.Integration;
|
|||||||
/// <c>BattleFinish</c> frame is played immediately whether or not it carries a <c>playSeq</c>).
|
/// <c>BattleFinish</c> frame is played immediately whether or not it carries a <c>playSeq</c>).
|
||||||
/// The check is on *body shape*.</para>
|
/// The check is on *body shape*.</para>
|
||||||
///
|
///
|
||||||
/// <para><b>Coverage:</b> a single Scripted session emits all ten server-authored URIs
|
/// <para><b>Coverage:</b> a two-client PvP session emits all ten server-authored URIs
|
||||||
/// (<c>InitNetwork, Matched, BattleStart, Deal, Swap, Ready, TurnStart, TurnEnd, Judge,
|
/// (<c>InitNetwork, Matched, BattleStart, Deal, Swap, Ready, TurnStart, TurnEnd, Judge,
|
||||||
/// BattleFinish</c>). PvP uses the same <see cref="SVSim.BattleNode.Lifecycle.ScriptedLifecycle"/>
|
/// BattleFinish</c>). PvP authors the handshake/mulligan frames through the same shared
|
||||||
/// builders for the handshake/mulligan frames, so this transitively covers the PvP handshake shape
|
/// <see cref="SVSim.BattleNode.Lifecycle.ServerBattleFrames"/> builders, and the turn cycle
|
||||||
/// too. Forwarded frames (<c>PlayActions / TurnEndActions / ChatStamp / TurnEndFinal</c>) relay the
|
/// (<c>TurnStart/TurnEnd/Judge</c>) falls out of the real two-client handover. Forwarded frames
|
||||||
|
/// (<c>PlayActions / TurnEndActions / ChatStamp / TurnEndFinal</c>) relay the
|
||||||
/// client's own bytes verbatim, so their shape is the client's contract, not ours — out of scope
|
/// client's own bytes verbatim, so their shape is the client's contract, not ours — out of scope
|
||||||
/// here.</para>
|
/// here.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class CaptureConformanceTests
|
public class CaptureConformanceTests
|
||||||
{
|
{
|
||||||
private const long ViewerId = 906243102L;
|
|
||||||
|
|
||||||
// Top-level keys that are envelope/transport, not body shape. Excluded from the comparison
|
// Top-level keys that are envelope/transport, not body shape. Excluded from the comparison
|
||||||
// at the root level only (nested objects never contain these).
|
// at the root level only (nested objects never contain these).
|
||||||
private static readonly HashSet<string> IgnoredEnvelopeKeys = new()
|
private static readonly HashSet<string> IgnoredEnvelopeKeys = new()
|
||||||
@@ -52,51 +51,76 @@ public class CaptureConformanceTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
[Timeout(30000)]
|
[Timeout(60000)]
|
||||||
public async Task ServerAuthoredFrames_MatchProdCaptureShapes()
|
public async Task ServerAuthoredFrames_MatchProdCaptureShapes()
|
||||||
{
|
{
|
||||||
await using var factory = new SVSimTestFactory();
|
await using var factory = new SVSimTestFactory();
|
||||||
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
|
||||||
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
||||||
var ct = cts.Token;
|
var ct = cts.Token;
|
||||||
|
|
||||||
|
// Two-client PvP drive. PvP authors the same handshake/mulligan frames the old Scripted
|
||||||
|
// path did (via the shared server-frame builders) PLUS the turn-cycle frames
|
||||||
|
// (TurnStart/TurnEnd/Judge) the scripted bot used to fake — so a two-client session
|
||||||
|
// harvests all ten server-authored URIs. The shape check is category-based, so PvP's
|
||||||
|
// spin:0 still matches prod's spin:189.
|
||||||
|
const long vidA = 906243102L;
|
||||||
|
const long vidB = 847666884L;
|
||||||
var pending = bridge.RegisterBattle(
|
var pending = bridge.RegisterBattle(
|
||||||
new BattlePlayer(ViewerId, BattleNodeFlowTests.FixtureCtx()),
|
new BattlePlayer(vidA, BattleNodeFlowTests.FixtureCtx()),
|
||||||
p2: null,
|
new BattlePlayer(vidB, BattleNodeFlowTests.FixtureCtx()),
|
||||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
SVSim.BattleNode.Sessions.BattleType.Pvp);
|
||||||
|
|
||||||
var key = MakeKey();
|
var key = MakeKey();
|
||||||
var encryptedVid = NodeCrypto.EncryptForNode(ViewerId.ToString(), key);
|
var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct);
|
||||||
var wsUri = new Uri(
|
await using var _a = clientA;
|
||||||
$"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket");
|
await using var _b = clientB;
|
||||||
|
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
|
||||||
|
|
||||||
var wsClient = factory.Server.CreateWebSocketClient();
|
|
||||||
var ws = await wsClient.ConnectAsync(wsUri, ct);
|
|
||||||
await using var client = new RawSocketIoTestClient(ws);
|
|
||||||
await client.ConsumeHandshakeAsync(ct);
|
|
||||||
|
|
||||||
// Drive the full Scripted lifecycle, harvesting every server-pushed frame by URI.
|
|
||||||
var harvested = new Dictionary<NetworkBattleUri, MsgEnvelope>();
|
var harvested = new Dictionary<NetworkBattleUri, MsgEnvelope>();
|
||||||
|
void Harvest(MsgEnvelope env) => harvested[env.Uri] = env;
|
||||||
|
|
||||||
async Task DriveAsync(NetworkBattleUri send, long pubSeq, int expectPushes,
|
long seqA = 1, seqB = 1;
|
||||||
Dictionary<string, object?>? body = null)
|
|
||||||
{
|
|
||||||
await client.SendMsgAsync(MakeEnvelope(send, pubSeq, body), key, ct);
|
|
||||||
for (var i = 0; i < expectPushes; i++)
|
|
||||||
{
|
|
||||||
var frame = await client.ReceiveSynchronizeAsync(ct);
|
|
||||||
harvested[frame.Uri] = frame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await DriveAsync(NetworkBattleUri.InitNetwork, 1, expectPushes: 1);
|
// A walks the handshake; Ready is withheld by the mulligan barrier until B also swaps.
|
||||||
await DriveAsync(NetworkBattleUri.InitBattle, 2, expectPushes: 1); // Matched
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitNetwork, seqA++), key, ct);
|
||||||
await DriveAsync(NetworkBattleUri.Loaded, 3, expectPushes: 2); // BattleStart + Deal
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // InitNetwork ack
|
||||||
await DriveAsync(NetworkBattleUri.Swap, 4, expectPushes: 2, // Swap + Ready
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitBattle, seqA++), key, ct);
|
||||||
body: new Dictionary<string, object?> { ["idxList"] = new List<object?>() });
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Matched
|
||||||
await DriveAsync(NetworkBattleUri.TurnEnd, 5, expectPushes: 3); // TurnStart + TurnEnd + Judge
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Loaded, seqA++), key, ct);
|
||||||
await DriveAsync(NetworkBattleUri.Retire, 6, expectPushes: 1); // BattleFinish
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // BattleStart
|
||||||
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Deal
|
||||||
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Swap, seqA++,
|
||||||
|
new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||||
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Swap response
|
||||||
|
|
||||||
|
// B walks the handshake; B's Swap (the second) releases Ready to both sides.
|
||||||
|
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitNetwork, seqB++), key, ct);
|
||||||
|
await clientB.ReceiveSynchronizeAsync(ct); // ack
|
||||||
|
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitBattle, seqB++), key, ct);
|
||||||
|
await clientB.ReceiveSynchronizeAsync(ct); // Matched
|
||||||
|
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Loaded, seqB++), key, ct);
|
||||||
|
await clientB.ReceiveSynchronizeAsync(ct); // BattleStart
|
||||||
|
await clientB.ReceiveSynchronizeAsync(ct); // Deal
|
||||||
|
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Swap, seqB++,
|
||||||
|
new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
|
||||||
|
await clientB.ReceiveSynchronizeAsync(ct); // B Swap response
|
||||||
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Ready (released to A)
|
||||||
|
await clientB.ReceiveSynchronizeAsync(ct); // Ready to B
|
||||||
|
|
||||||
|
// Turn cycle: A ends turn -> B receives TurnEnd{turnState}. B sends Judge -> Judge{spin}
|
||||||
|
// reflects to B. B sends TurnStart -> A receives TurnStart{spin}.
|
||||||
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.TurnEnd, seqA++), key, ct);
|
||||||
|
Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // TurnEnd
|
||||||
|
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Judge, seqB++), key, ct);
|
||||||
|
Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // Judge
|
||||||
|
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.TurnStart, seqB++), key, ct);
|
||||||
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // TurnStart
|
||||||
|
|
||||||
|
// BattleFinish: A retires.
|
||||||
|
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Retire, seqA++), key, ct);
|
||||||
|
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // BattleFinish
|
||||||
|
|
||||||
// Compare each harvested frame's wire JSON against the prod capture fixture.
|
// Compare each harvested frame's wire JSON against the prod capture fixture.
|
||||||
using var fixtures = JsonDocument.Parse(ProdCaptureFixture.Json);
|
using var fixtures = JsonDocument.Parse(ProdCaptureFixture.Json);
|
||||||
@@ -107,7 +131,7 @@ public class CaptureConformanceTests
|
|||||||
var uri = Enum.Parse<NetworkBattleUri>(uriName);
|
var uri = Enum.Parse<NetworkBattleUri>(uriName);
|
||||||
if (!harvested.TryGetValue(uri, out var env))
|
if (!harvested.TryGetValue(uri, out var env))
|
||||||
{
|
{
|
||||||
failures.Add($"[{uriName}] our server never pushed this frame during the Scripted lifecycle.");
|
failures.Add($"[{uriName}] our server never pushed this frame during the PvP lifecycle.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +148,22 @@ public class CaptureConformanceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<(RawSocketIoTestClient, RawSocketIoTestClient)> ConnectBothAsync(
|
||||||
|
SVSimTestFactory factory, string battleId, long vidA, long vidB, string key, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var encA = NodeCrypto.EncryptForNode(vidA.ToString(), key);
|
||||||
|
var encB = NodeCrypto.EncryptForNode(vidB.ToString(), key);
|
||||||
|
var uriA = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encA)}&EIO=3&transport=websocket");
|
||||||
|
var uriB = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encB)}&EIO=3&transport=websocket");
|
||||||
|
|
||||||
|
var wsClient = factory.Server.CreateWebSocketClient();
|
||||||
|
var connectATask = wsClient.ConnectAsync(uriA, ct);
|
||||||
|
await Task.Delay(50, ct);
|
||||||
|
var wsB = await wsClient.ConnectAsync(uriB, ct);
|
||||||
|
var wsA = await connectATask;
|
||||||
|
return (new RawSocketIoTestClient(wsA), new RawSocketIoTestClient(wsB));
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly string[] ExpectedUris =
|
private static readonly string[] ExpectedUris =
|
||||||
{
|
{
|
||||||
"InitNetwork", "Matched", "BattleStart", "Deal", "Swap", "Ready",
|
"InitNetwork", "Matched", "BattleStart", "Deal", "Swap", "Ready",
|
||||||
@@ -208,9 +248,9 @@ public class CaptureConformanceTests
|
|||||||
return s.Length > 40 ? s[..40] + "…" : s;
|
return s.Length > 40 ? s[..40] + "…" : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq,
|
private static MsgEnvelope MakeEnvelope(long vid, NetworkBattleUri uri, long pubSeq,
|
||||||
Dictionary<string, object?>? body = null) =>
|
Dictionary<string, object?>? body = null) =>
|
||||||
new(uri, ViewerId: ViewerId, Uuid: "udid-test", Bid: null, Try: 0,
|
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0,
|
||||||
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
|
||||||
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
|
||||||
: EmitCategory.Battle,
|
: EmitCategory.Battle,
|
||||||
@@ -221,6 +261,48 @@ public class CaptureConformanceTests
|
|||||||
var seq = 0;
|
var seq = 0;
|
||||||
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
|
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SynthesizedKnownList_matches_prod_recv_PlayActions_entry_shape()
|
||||||
|
{
|
||||||
|
// Prod recv PlayActions knownList entry (battle-traffic_tk2_regular.ndjson:27).
|
||||||
|
const string prodEntry = """
|
||||||
|
{ "idx": 17, "cardId": 128821011, "to": 20, "cost": 2, "clan": 8, "tribe": "7,16", "spellboost": 0, "attachTarget": "" }
|
||||||
|
""";
|
||||||
|
|
||||||
|
// Build the same entry through our synthesizer.
|
||||||
|
var deckMap = new Dictionary<int, long> { [17] = 128821011L };
|
||||||
|
var orderList = new List<object?>
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["move"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["idx"] = new List<object?> { 17L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var entry = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.BuildPlayedCard(deckMap, 17, orderList);
|
||||||
|
Assert.That(entry, Is.Not.Null);
|
||||||
|
|
||||||
|
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||||
|
PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
||||||
|
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||||
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||||
|
|
||||||
|
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||||
|
var ourEntry = ourDoc.RootElement.GetProperty("knownList")[0];
|
||||||
|
using var prodDoc = JsonDocument.Parse(prodEntry);
|
||||||
|
|
||||||
|
// We are responsible for idx/cardId/to (+ spellboost/attachTarget). cost/clan/tribe are deferred.
|
||||||
|
foreach (var key in new[] { "idx", "cardId", "to" })
|
||||||
|
{
|
||||||
|
Assert.That(ourEntry.TryGetProperty(key, out var ours), Is.True, $"knownList entry missing '{key}'");
|
||||||
|
var prodVal = prodDoc.RootElement.GetProperty(key);
|
||||||
|
Assert.That(ours.ValueKind, Is.EqualTo(prodVal.ValueKind), $"'{key}' type category mismatch");
|
||||||
|
}
|
||||||
|
Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(128821011L));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ using SVSim.BattleNode.Protocol.Bodies;
|
|||||||
namespace SVSim.UnitTests.BattleNode.Lifecycle;
|
namespace SVSim.UnitTests.BattleNode.Lifecycle;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class ScriptedLifecycleTests
|
public class ServerBattleFramesTests
|
||||||
{
|
{
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildMatched_PutsOppoIdInSelfInfoEqualToTheRealOpponentVid()
|
public void BuildMatched_PutsOppoIdInSelfInfoEqualToTheRealOpponentVid()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(),
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||||
selfViewerId: 906243102, oppoViewerId: 847666884,
|
selfViewerId: 906243102, oppoViewerId: 847666884,
|
||||||
battleId: "b", seed: ScriptedProfiles.BattleSeed);
|
battleId: "b", seed: BattleFrameDefaults.BattleSeed);
|
||||||
|
|
||||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
@@ -26,7 +26,7 @@ public class ScriptedLifecycleTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildMatched_ContainsThirtyCardSelfDeck()
|
public void BuildMatched_ContainsThirtyCardSelfDeck()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(), 1, 2, "b", ScriptedProfiles.BattleSeed);
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
|
Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ public class ScriptedLifecycleTests
|
|||||||
public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids()
|
public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids()
|
||||||
{
|
{
|
||||||
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
|
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
|
||||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(draftedDeck), ScriptedBotCtx(), 1, 2, "b", ScriptedProfiles.BattleSeed);
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
|
|
||||||
for (int i = 0; i < 30; i++)
|
for (int i = 0; i < 30; i++)
|
||||||
@@ -56,7 +56,7 @@ public class ScriptedLifecycleTests
|
|||||||
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
|
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
var env = ScriptedLifecycle.BuildMatched(ctx, ScriptedBotCtx(), 1, 2, "b", ScriptedProfiles.BattleSeed);
|
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed);
|
||||||
var body = (MatchedBody)env.Body;
|
var body = (MatchedBody)env.Body;
|
||||||
|
|
||||||
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
|
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
|
||||||
@@ -71,7 +71,7 @@ public class ScriptedLifecycleTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleType()
|
public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleType()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 1);
|
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 1, turnState: 0);
|
||||||
var body = (BattleStartBody)env.Body;
|
var body = (BattleStartBody)env.Body;
|
||||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
Assert.That(body.TurnState, Is.EqualTo(0));
|
||||||
Assert.That(body.BattleType, Is.EqualTo(11));
|
Assert.That(body.BattleType, Is.EqualTo(11));
|
||||||
@@ -87,7 +87,7 @@ public class ScriptedLifecycleTests
|
|||||||
BattleType = 42,
|
BattleType = 42,
|
||||||
};
|
};
|
||||||
|
|
||||||
var env = ScriptedLifecycle.BuildBattleStart(ctx, ScriptedBotCtx(), selfViewerId: 1);
|
var env = ServerBattleFrames.BuildBattleStart(ctx, FakeOpponentCtx(), selfViewerId: 1, turnState: 0);
|
||||||
var body = (BattleStartBody)env.Body;
|
var body = (BattleStartBody)env.Body;
|
||||||
|
|
||||||
Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7"));
|
Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7"));
|
||||||
@@ -99,7 +99,7 @@ public class ScriptedLifecycleTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildDeal_HasThreeSelfAndThreeOppoEntries()
|
public void BuildDeal_HasThreeSelfAndThreeOppoEntries()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildDeal();
|
var env = ServerBattleFrames.BuildDeal();
|
||||||
var body = (DealBody)env.Body;
|
var body = (DealBody)env.Body;
|
||||||
Assert.That(body.Self.Count, Is.EqualTo(3));
|
Assert.That(body.Self.Count, Is.EqualTo(3));
|
||||||
Assert.That(body.Oppo.Count, Is.EqualTo(3));
|
Assert.That(body.Oppo.Count, Is.EqualTo(3));
|
||||||
@@ -108,28 +108,28 @@ public class ScriptedLifecycleTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void ComputeHandAfterSwap_NoSwap_ReturnsInitialHand()
|
public void ComputeHandAfterSwap_NoSwap_ReturnsInitialHand()
|
||||||
{
|
{
|
||||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(Array.Empty<long>());
|
var hand = ServerBattleFrames.ComputeHandAfterSwap(Array.Empty<long>());
|
||||||
Assert.That(hand, Is.EqualTo(new long[] { 1, 2, 3 }));
|
Assert.That(hand, Is.EqualTo(new long[] { 1, 2, 3 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ComputeHandAfterSwap_SwapMiddleCard_ReplacesWithFreshDeckIdx()
|
public void ComputeHandAfterSwap_SwapMiddleCard_ReplacesWithFreshDeckIdx()
|
||||||
{
|
{
|
||||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(new long[] { 2 });
|
var hand = ServerBattleFrames.ComputeHandAfterSwap(new long[] { 2 });
|
||||||
Assert.That(hand, Is.EqualTo(new long[] { 1, 4, 3 }));
|
Assert.That(hand, Is.EqualTo(new long[] { 1, 4, 3 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ComputeHandAfterSwap_SwapAll_ReplacesAllWithFreshDeckIdxs()
|
public void ComputeHandAfterSwap_SwapAll_ReplacesAllWithFreshDeckIdxs()
|
||||||
{
|
{
|
||||||
var hand = ScriptedLifecycle.ComputeHandAfterSwap(new long[] { 1, 2, 3 });
|
var hand = ServerBattleFrames.ComputeHandAfterSwap(new long[] { 1, 2, 3 });
|
||||||
Assert.That(hand, Is.EqualTo(new long[] { 4, 5, 6 }));
|
Assert.That(hand, Is.EqualTo(new long[] { 4, 5, 6 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildSwapResponse_RendersGivenHandAsPositions()
|
public void BuildSwapResponse_RendersGivenHandAsPositions()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 });
|
var env = ServerBattleFrames.BuildSwapResponse(new long[] { 1, 4, 3 });
|
||||||
var body = (SwapResponseBody)env.Body;
|
var body = (SwapResponseBody)env.Body;
|
||||||
Assert.That(body.Self.Count, Is.EqualTo(3));
|
Assert.That(body.Self.Count, Is.EqualTo(3));
|
||||||
Assert.That(body.Self[1].Idx, Is.EqualTo(4));
|
Assert.That(body.Self[1].Idx, Is.EqualTo(4));
|
||||||
@@ -138,7 +138,7 @@ public class ScriptedLifecycleTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand()
|
public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 });
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
||||||
var body = (ReadyBody)env.Body;
|
var body = (ReadyBody)env.Body;
|
||||||
Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280));
|
Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280));
|
||||||
Assert.That(body.Spin, Is.EqualTo(243));
|
Assert.That(body.Spin, Is.EqualTo(243));
|
||||||
@@ -146,38 +146,24 @@ public class ScriptedLifecycleTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildOpponentTurnStart_HasUriTurnStartAndSpin()
|
public void BuildReady_two_arg_sets_oppo_to_supplied_hand()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildOpponentTurnStart();
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 });
|
||||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
var body = (ReadyBody)env.Body;
|
||||||
var body = (OpponentTurnStartBody)env.Body;
|
|
||||||
Assert.That(body.Spin, Is.EqualTo(100));
|
Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 }));
|
||||||
|
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 6 }),
|
||||||
|
"oppo must reflect the opponent's post-mulligan hand, not the placeholder InitialHand.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void BuildOpponentTurnEnd_emits_TurnEnd_uri_with_turn_state_zero()
|
public void BuildReady_one_arg_defaults_oppo_to_InitialHand()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildOpponentTurnEnd();
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
||||||
|
var body = (ReadyBody)env.Body;
|
||||||
|
|
||||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }),
|
||||||
Assert.That(env.ViewerId, Is.EqualTo(ScriptedLifecycle.FakeOpponentViewerId));
|
"single-arg overload (non-interactive opponent) keeps the placeholder hand.");
|
||||||
Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle));
|
|
||||||
var body = (TurnEndBody)env.Body;
|
|
||||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
|
||||||
Assert.That(body.ResultCode, Is.EqualTo(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void BuildOpponentJudge_emits_Judge_uri_with_spin_and_default_result_code()
|
|
||||||
{
|
|
||||||
var env = ScriptedLifecycle.BuildOpponentJudge();
|
|
||||||
|
|
||||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
|
||||||
Assert.That(env.ViewerId, Is.EqualTo(ScriptedLifecycle.FakeOpponentViewerId));
|
|
||||||
Assert.That(env.Cat, Is.EqualTo(EmitCategory.Battle));
|
|
||||||
var body = (JudgeBody)env.Body;
|
|
||||||
Assert.That(body.Spin, Is.EqualTo(ScriptedProfiles.OpponentJudgeSpin));
|
|
||||||
Assert.That(body.ResultCode, Is.EqualTo(1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
private static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
|
||||||
@@ -187,9 +173,9 @@ public class ScriptedLifecycleTests
|
|||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleType: 11);
|
||||||
|
|
||||||
// Mirrors ScriptedBotParticipant.Context — the scripted opponent's MatchContext fixture
|
// A prod-captured opponent MatchContext fixture that the BuildMatched/BuildBattleStart
|
||||||
// that the new BuildMatched/BuildBattleStart helpers read from for the oppo half.
|
// helpers read from for the oppo half.
|
||||||
private static MatchContext ScriptedBotCtx() => new(
|
private static MatchContext FakeOpponentCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||||
@@ -27,8 +27,8 @@ public class TypedBodyWireShapeTests
|
|||||||
// Matching.StartBattleLoad reads it back, and GetSelfDeck().Select(...) crashes
|
// Matching.StartBattleLoad reads it back, and GetSelfDeck().Select(...) crashes
|
||||||
// with "Value cannot be null. Parameter name: source". The prod wire format
|
// with "Value cannot be null. Parameter name: source". The prod wire format
|
||||||
// emits envelope keys (uri first) before body keys; we must too.
|
// emits envelope keys (uri first) before body keys; we must too.
|
||||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(),
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||||
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: ScriptedProfiles.BattleSeed);
|
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: BattleFrameDefaults.BattleSeed);
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
|
|
||||||
var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal);
|
var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal);
|
||||||
@@ -45,9 +45,9 @@ public class TypedBodyWireShapeTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildMatched_SerializesAllWireKeysExpectedByTheClient()
|
public void BuildMatched_SerializesAllWireKeysExpectedByTheClient()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildMatched(FixtureCtx(), ScriptedBotCtx(),
|
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||||
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
|
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
|
||||||
seed: ScriptedProfiles.BattleSeed);
|
seed: BattleFrameDefaults.BattleSeed);
|
||||||
|
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
@@ -86,7 +86,7 @@ public class TypedBodyWireShapeTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry()
|
public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildBattleStart(FixtureCtx(), ScriptedBotCtx(), selfViewerId: 906243102);
|
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, turnState: 0);
|
||||||
|
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
@@ -109,7 +109,7 @@ public class TypedBodyWireShapeTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildDeal_SerializesSelfAndOppoArraysWithPosIdxShape()
|
public void BuildDeal_SerializesSelfAndOppoArraysWithPosIdxShape()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildDeal();
|
var env = ServerBattleFrames.BuildDeal();
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ public class TypedBodyWireShapeTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildSwapResponse_SerializesSelfWithoutOppo()
|
public void BuildSwapResponse_SerializesSelfWithoutOppo()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildSwapResponse(new long[] { 1, 4, 3 });
|
var env = ServerBattleFrames.BuildSwapResponse(new long[] { 1, 4, 3 });
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ public class TypedBodyWireShapeTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin()
|
public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin()
|
||||||
{
|
{
|
||||||
var env = ScriptedLifecycle.BuildReady(new long[] { 1, 4, 3 });
|
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 });
|
||||||
var json = MsgEnvelope.ToJson(env);
|
var json = MsgEnvelope.ToJson(env);
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
var node = JsonNode.Parse(json)!.AsObject();
|
||||||
|
|
||||||
@@ -147,42 +147,6 @@ public class TypedBodyWireShapeTests
|
|||||||
Assert.That(node["oppo"]!.AsArray().Count, Is.EqualTo(3));
|
Assert.That(node["oppo"]!.AsArray().Count, Is.EqualTo(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void BuildOpponentTurnStart_SerializesSpinAndResultCode()
|
|
||||||
{
|
|
||||||
var env = ScriptedLifecycle.BuildOpponentTurnStart();
|
|
||||||
var json = MsgEnvelope.ToJson(env);
|
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
|
||||||
|
|
||||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(100));
|
|
||||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
|
||||||
Assert.That(node["uri"]!.GetValue<string>(), Is.EqualTo("TurnStart"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void BuildOpponentTurnEnd_SerializesTurnStateAndResultCode()
|
|
||||||
{
|
|
||||||
var env = ScriptedLifecycle.BuildOpponentTurnEnd();
|
|
||||||
var json = MsgEnvelope.ToJson(env);
|
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
|
||||||
|
|
||||||
Assert.That(node["turnState"]!.GetValue<int>(), Is.EqualTo(0));
|
|
||||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
|
||||||
Assert.That(node["uri"]!.GetValue<string>(), Is.EqualTo("TurnEnd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void BuildOpponentJudge_SerializesSpinAndResultCode()
|
|
||||||
{
|
|
||||||
var env = ScriptedLifecycle.BuildOpponentJudge();
|
|
||||||
var json = MsgEnvelope.ToJson(env);
|
|
||||||
var node = JsonNode.Parse(json)!.AsObject();
|
|
||||||
|
|
||||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(100));
|
|
||||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
|
||||||
Assert.That(node["uri"]!.GetValue<string>(), Is.EqualTo("Judge"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wire-shape fixture: 30 copies of the legacy DummyCardId (100_011_010L) so the
|
/// Wire-shape fixture: 30 copies of the legacy DummyCardId (100_011_010L) so the
|
||||||
/// existing literal assertions on <c>selfDeck[0].cardId</c> (line 81 above) keep working
|
/// existing literal assertions on <c>selfDeck[0].cardId</c> (line 81 above) keep working
|
||||||
@@ -195,11 +159,11 @@ public class TypedBodyWireShapeTests
|
|||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleType: 11);
|
||||||
|
|
||||||
// Mirrors ScriptedBotParticipant.Context — 30-card deck and the prod-captured opponent
|
// Prod-captured opponent fixture — 30-card deck and the prod-captured opponent
|
||||||
// cosmetics (ClassId/CharaId "8") so the wire bytes asserted below (oppoInfo classId/charaId,
|
// cosmetics (ClassId/CharaId "8") so the wire bytes asserted below (oppoInfo classId/charaId,
|
||||||
// oppoDeckCount=30, etc.) remain byte-identical after the BuildMatched/BuildBattleStart
|
// oppoDeckCount=30, etc.) remain byte-identical after the BuildMatched/BuildBattleStart
|
||||||
// signature change.
|
// signature change.
|
||||||
private static MatchContext ScriptedBotCtx() => new(
|
private static MatchContext FakeOpponentCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||||
|
|||||||
@@ -13,115 +13,27 @@ namespace SVSim.UnitTests.BattleNode.Sessions;
|
|||||||
public class BattleSessionDispatchTests
|
public class BattleSessionDispatchTests
|
||||||
{
|
{
|
||||||
[Test]
|
[Test]
|
||||||
public void InitNetwork_acks_to_sender_transitions_to_AwaitingInitBattle()
|
public void Pvp_Loaded_from_A_assigns_turnState_0()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewSession();
|
var (s, a, _) = NewPvpSession();
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
|
|
||||||
Assert.That(routes[0].NoStock, Is.True);
|
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void InitBattle_pushes_Matched_to_sender_only()
|
|
||||||
{
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Loaded_pushes_BattleStart_then_Deal_to_sender()
|
|
||||||
{
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
|
||||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
var bs = (BattleStartBody)routes[0].Frame.Body;
|
||||||
Is.EqualTo(new[] { NetworkBattleUri.BattleStart, NetworkBattleUri.Deal }));
|
Assert.That(bs.TurnState, Is.EqualTo(0), "A (first arriver) goes first.");
|
||||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Swap_pushes_SwapResponse_then_Ready_to_sender()
|
public void Pvp_Loaded_from_B_assigns_turnState_1()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewSession();
|
var (s, _, b) = NewPvpSession();
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
|
||||||
|
|
||||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
var bs = (BattleStartBody)routes[0].Frame.Body;
|
||||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
Assert.That(bs.TurnState, Is.EqualTo(1), "B (second arriver) goes second.");
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TurnEnd_from_real_forwards_to_other_participant()
|
|
||||||
{
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Scripted_TurnEndFinal_forwards_envelope_and_pushes_paired_BattleFinish()
|
|
||||||
{
|
|
||||||
// Unified TurnEndFinal handling: forward the envelope to other (matches prod
|
|
||||||
// capture battle-traffic_tk2_regular.ndjson:273) + push BattleFinish per-side
|
|
||||||
// with player-perspective codes (LifeWin to winner, LifeLose to loser).
|
|
||||||
// In Scripted mode the "loser" is a ScriptedBotParticipant; the loser-side
|
|
||||||
// BattleFinish push is harmless (bot swallows non-TurnEnd URIs).
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(3),
|
|
||||||
"TurnEndFinal must produce: forwarded envelope + BattleFinish(LifeWin) to from + BattleFinish(LifeLose) to other.");
|
|
||||||
|
|
||||||
// Route 0: forwarded TurnEndFinal envelope to other.
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
|
|
||||||
|
|
||||||
// Route 1: BattleFinish(LifeWin) to from (the winner who declared the final turn).
|
|
||||||
Assert.That(routes[1].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
||||||
Assert.That(routes[1].NoStock, Is.True);
|
|
||||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
|
||||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.LifeWin),
|
|
||||||
"Winner gets LifeWin (101) — player-perspective: 'I won by life' → WIN UI.");
|
|
||||||
|
|
||||||
// Route 2: BattleFinish(LifeLose) to other (the loser).
|
|
||||||
Assert.That(routes[2].Target, Is.SameAs(b));
|
|
||||||
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
||||||
Assert.That(routes[2].NoStock, Is.True);
|
|
||||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[2].Frame.Body;
|
|
||||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.LifeLose),
|
|
||||||
"Loser gets LifeLose (102) — player-perspective: 'I lost by life' → LOSE UI.");
|
|
||||||
|
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal),
|
|
||||||
"Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -147,102 +59,10 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void ScriptedBot_emitted_OpponentTurnStart_forwards_to_real()
|
|
||||||
{
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
// Bot emits a TurnStart frame (carrying ViewerId == FakeOpponentViewerId per the
|
|
||||||
// ScriptedBotParticipant impl). Session should route it to the real participant.
|
|
||||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnStart();
|
|
||||||
var routes = s.ComputeFrames(b, botFrame);
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void ScriptedBot_emitted_Judge_forwards_to_real()
|
|
||||||
{
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
var botFrame = ScriptedLifecycle.BuildOpponentJudge();
|
|
||||||
var routes = s.ComputeFrames(b, botFrame);
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void ScriptedBot_emitted_TurnEnd_forwards_to_real()
|
|
||||||
{
|
|
||||||
// TurnEnd from the bot is also one of the burst frames. The case is handled
|
|
||||||
// by the TurnEnd-from-scripted arm (bot ViewerId matches FakeOpponentViewerId).
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
|
||||||
// Drive player TurnEnd first (transitions Phase if needed) — but TurnEnd from bot
|
|
||||||
// arrives in Phase AfterReady too. The bot's TurnEnd is routed via the dispatch
|
|
||||||
// arm that forwards any frame from the FakeOpponentViewerId participant.
|
|
||||||
|
|
||||||
var botFrame = ScriptedLifecycle.BuildOpponentTurnEnd();
|
|
||||||
var routes = s.ComputeFrames(b, botFrame);
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Retire_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
|
||||||
{
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(2));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
||||||
Assert.That(routes[0].NoStock, Is.True);
|
|
||||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
|
||||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose),
|
|
||||||
"Retirer gets RetireLose=106 — player-perspective: 'I lost by retire'.");
|
|
||||||
|
|
||||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
|
||||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
||||||
Assert.That(routes[1].NoStock, Is.True);
|
|
||||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
|
||||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin),
|
|
||||||
"Survivor gets RetireWin=105. In Scripted mode the bot swallows it; in PvP the opponent renders 'opponent retired'.");
|
|
||||||
|
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Kill_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
|
||||||
{
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(2));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
||||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
|
||||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose));
|
|
||||||
|
|
||||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
|
||||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
|
||||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
|
||||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin));
|
|
||||||
|
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase()
|
public void OutOfOrder_dispatch_returns_empty_and_does_not_advance_phase()
|
||||||
{
|
{
|
||||||
var (s, a, _) = NewSession();
|
var (s, a, _) = NewPvpSession();
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
Assert.That(routes, Is.Empty);
|
Assert.That(routes, Is.Empty);
|
||||||
@@ -304,7 +124,7 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_Swap_from_A_pushes_SwapResponse_plus_Ready_to_A_only()
|
public void Pvp_Swap_from_A_alone_pushes_SwapResponse_only_Ready_withheld()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
@@ -312,16 +132,41 @@ public class BattleSessionDispatchTests
|
|||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
Assert.That(routes.Select(r => r.Frame.Uri),
|
Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }),
|
||||||
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
|
"Ready is withheld until BOTH sides have mulliganed.");
|
||||||
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
|
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady),
|
||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
"Phase advances on Swap even though Ready is withheld.");
|
||||||
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork),
|
|
||||||
"Swap from A doesn't advance B's phase.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_TurnStart_from_A_in_BothAfterReady_forwards_to_B()
|
public void Pvp_Swap_from_both_releases_Ready_to_both_with_opponent_hands()
|
||||||
|
{
|
||||||
|
var (s, a, b) = NewPvpSession();
|
||||||
|
foreach (var p in new[] { a, b })
|
||||||
|
{
|
||||||
|
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
|
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
|
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
var aRoutes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // first swapper
|
||||||
|
Assert.That(aRoutes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }));
|
||||||
|
|
||||||
|
var bRoutes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Swap)); // second swapper releases both
|
||||||
|
// Expect: B's own SwapResponse, then Ready to B, then Ready to A.
|
||||||
|
Assert.That(bRoutes.Count, Is.EqualTo(3));
|
||||||
|
Assert.That(bRoutes[0].Target, Is.SameAs(b));
|
||||||
|
Assert.That(bRoutes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Swap));
|
||||||
|
|
||||||
|
var readyToB = bRoutes.Single(r => ReferenceEquals(r.Target, b) && r.Frame.Uri == NetworkBattleUri.Ready);
|
||||||
|
var readyToA = bRoutes.Single(r => ReferenceEquals(r.Target, a) && r.Frame.Uri == NetworkBattleUri.Ready);
|
||||||
|
// Empty mulligans → each hand is the dealt [1,2,3]; oppo mirrors the other side's hand.
|
||||||
|
Assert.That(((ReadyBody)readyToB.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }));
|
||||||
|
Assert.That(((ReadyBody)readyToA.Frame.Body).Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pvp_TurnStart_from_A_emits_spin0_to_B()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
@@ -332,24 +177,111 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||||
|
var body = (SVSim.BattleNode.Protocol.Bodies.OpponentTurnStartBody)routes[0].Frame.Body;
|
||||||
|
Assert.That(body.Spin, Is.EqualTo(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_PlayActions_from_A_in_BothAfterReady_forwards_to_B()
|
public void Pvp_Judge_from_A_reflects_spin0_back_to_sender()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
DriveToAfterReady(s, b);
|
DriveToAfterReady(s, b);
|
||||||
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Judge));
|
||||||
|
|
||||||
|
// Judge reflects BACK to its sender (the turn taker-over), not to the opponent: receiving
|
||||||
|
// Judge{spin} fires the sender's ControlTurnStartPlayer. Routing to the opponent would
|
||||||
|
// restart the just-ended player's turn (2026-06-03 two-client capture).
|
||||||
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||||
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||||
|
var body = (SVSim.BattleNode.Protocol.Bodies.JudgeBody)routes[0].Frame.Body;
|
||||||
|
Assert.That(body.Spin, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pvp_PlayActions_synthesizes_knownList_from_sender_deck()
|
||||||
|
{
|
||||||
|
var (s, a, b) = NewPvpSession();
|
||||||
|
DriveToAfterReady(s, a);
|
||||||
|
DriveToAfterReady(s, b);
|
||||||
|
|
||||||
|
var body = MoveOrderList(idx: 3, from: 10, to: 20);
|
||||||
|
body["playIdx"] = 3L;
|
||||||
|
body["type"] = 30L;
|
||||||
|
|
||||||
|
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
||||||
|
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||||
|
Assert.That(pb.PlayIdx, Is.EqualTo(3));
|
||||||
|
Assert.That(pb.Type, Is.EqualTo(30));
|
||||||
|
Assert.That(pb.KnownList!.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(pb.KnownList[0].Idx, Is.EqualTo(3));
|
||||||
|
Assert.That(pb.KnownList[0].CardId, Is.EqualTo(100_011_010L)); // PlayerACtx deck cardId
|
||||||
|
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
|
||||||
|
Assert.That(pb.OppoTargetList, Is.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_Echo_from_A_in_BothAfterReady_forwards_to_B()
|
public void Pvp_PlayActions_renames_targetList_to_oppoTargetList()
|
||||||
|
{
|
||||||
|
var (s, a, b) = NewPvpSession();
|
||||||
|
DriveToAfterReady(s, a);
|
||||||
|
DriveToAfterReady(s, b);
|
||||||
|
|
||||||
|
var body = MoveOrderList(idx: 3, from: 10, to: 20);
|
||||||
|
body["playIdx"] = 3L;
|
||||||
|
body["type"] = 31L;
|
||||||
|
body["targetList"] = new List<object?>
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?> { ["targetIdx"] = 8L, ["isSelf"] = 0L },
|
||||||
|
};
|
||||||
|
|
||||||
|
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||||
|
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||||
|
|
||||||
|
Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8));
|
||||||
|
Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pvp_PlayActions_token_idx_degrades_to_no_knownList()
|
||||||
|
{
|
||||||
|
var (s, a, b) = NewPvpSession();
|
||||||
|
DriveToAfterReady(s, a);
|
||||||
|
DriveToAfterReady(s, b);
|
||||||
|
|
||||||
|
var body = MoveOrderList(idx: 31, from: 10, to: 20); // idx 31 > 30-card deck → token
|
||||||
|
body["playIdx"] = 31L;
|
||||||
|
body["type"] = 30L;
|
||||||
|
|
||||||
|
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||||
|
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||||
|
|
||||||
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(pb.PlayIdx, Is.EqualTo(31));
|
||||||
|
Assert.That(pb.KnownList, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
||||||
|
{
|
||||||
|
var (s, a, b) = NewPvpSession();
|
||||||
|
DriveToAfterReady(s, a);
|
||||||
|
// B not AfterReady → not BothAfterReady.
|
||||||
|
var body = MoveOrderList(3, 10, 20);
|
||||||
|
body["playIdx"] = 3L; body["type"] = 30L;
|
||||||
|
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||||
|
Assert.That(routes, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
@@ -357,21 +289,23 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Echo));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Echo));
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
Assert.That(routes, Is.Empty, "Echo has no inbound handler on the client; relaying risks an echo storm.");
|
||||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_TurnEndActions_from_A_in_BothAfterReady_forwards_to_B()
|
public void Pvp_TurnEndActions_from_A_emits_empty_body_to_B()
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
DriveToAfterReady(s, b);
|
DriveToAfterReady(s, b);
|
||||||
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndActions));
|
var body = MoveOrderList(3, 20, 30); // a non-empty orderList that must be dropped
|
||||||
|
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.TurnEndActions, body));
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(1));
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||||
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndActions));
|
||||||
|
Assert.That(((RawBody)routes[0].Frame.Body).Entries, Is.Empty, "orderList is dropped; body is empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -388,20 +322,7 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
public void Pvp_TurnEnd_from_A_emits_turnState_to_B_only()
|
||||||
{
|
|
||||||
var (s, a, b) = NewPvpSession();
|
|
||||||
DriveToAfterReady(s, a);
|
|
||||||
// B is still AwaitingInitNetwork — BothAfterReady is false.
|
|
||||||
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
|
||||||
|
|
||||||
Assert.That(routes, Is.Empty,
|
|
||||||
"PvP gameplay forwarding must wait until BOTH sides reach AfterReady.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Pvp_TurnEnd_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
|
|
||||||
{
|
{
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
@@ -409,20 +330,17 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(4));
|
Assert.That(routes.Count, Is.EqualTo(1));
|
||||||
Assert.That(routes.Select(r => (r.Target, r.Frame.Uri)), Is.EquivalentTo(new[]
|
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||||
{
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||||
((IBattleParticipant)a, NetworkBattleUri.TurnEnd),
|
var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body;
|
||||||
((IBattleParticipant)b, NetworkBattleUri.TurnEnd),
|
Assert.That(body.TurnState, Is.EqualTo(0));
|
||||||
((IBattleParticipant)a, NetworkBattleUri.Judge),
|
|
||||||
((IBattleParticipant)b, NetworkBattleUri.Judge),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
|
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
|
||||||
{
|
{
|
||||||
// Same unified handling as Scripted — A is the winner, B is the loser.
|
// Unified TurnEndFinal handling — A is the winner, B is the loser.
|
||||||
var (s, a, b) = NewPvpSession();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
DriveToAfterReady(s, b);
|
DriveToAfterReady(s, b);
|
||||||
@@ -491,27 +409,10 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Scripted_Retire_pushes_RetireLose_to_player_and_RetireWin_to_bot()
|
|
||||||
{
|
|
||||||
// Unified with PvP — paired BattleFinish per-side. In Scripted mode the "loser"
|
|
||||||
// is a ScriptedBotParticipant; its loser-side push is swallowed (it only reacts
|
|
||||||
// to TurnEnd). The wire-correct codes are still emitted in case future work
|
|
||||||
// wants to inspect them or run a real two-real-participant session.
|
|
||||||
var (s, a, b) = NewSession();
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(2));
|
|
||||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
|
||||||
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
|
|
||||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
|
||||||
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
||||||
{
|
{
|
||||||
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
|
var a = new FakeRealParticipant(viewerId: 1001, PlayerACtx());
|
||||||
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, NoOpBotContext());
|
var b = new FakeParticipant(viewerId: ServerBattleFrames.FakeOpponentViewerId, NoOpBotContext());
|
||||||
var s = new BattleSession("bid-bot-1", BattleType.Bot, a, b, NullLogger<BattleSession>.Instance);
|
var s = new BattleSession("bid-bot-1", BattleType.Bot, a, b, NullLogger<BattleSession>.Instance);
|
||||||
return (s, a, b);
|
return (s, a, b);
|
||||||
}
|
}
|
||||||
@@ -572,6 +473,7 @@ public class BattleSessionDispatchTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Bot_Swap_per_sender_SwapResponse_plus_Ready()
|
public void Bot_Swap_per_sender_SwapResponse_plus_Ready()
|
||||||
{
|
{
|
||||||
|
// Opponent stub is not IHasHandshakePhase → not a barrier swapper → Ready releases immediately.
|
||||||
var (s, a, _) = NewBotSession();
|
var (s, a, _) = NewBotSession();
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
@@ -637,7 +539,7 @@ public class BattleSessionDispatchTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Bot_Retire_pushes_paired_BattleFinish_RetireLose_to_player_RetireWin_to_bot()
|
public void Bot_Retire_pushes_paired_BattleFinish_RetireLose_to_player_RetireWin_to_bot()
|
||||||
{
|
{
|
||||||
// Unified Retire/Kill dispatch — same paired push as Scripted and PvP.
|
// Unified Retire/Kill dispatch — same paired push as PvP.
|
||||||
// NoOpBotParticipant swallows its push.
|
// NoOpBotParticipant swallows its push.
|
||||||
var (s, a, b) = NewBotSession();
|
var (s, a, b) = NewBotSession();
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||||
@@ -685,14 +587,6 @@ public class BattleSessionDispatchTests
|
|||||||
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0,
|
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleType: 11);
|
||||||
|
|
||||||
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewSession()
|
|
||||||
{
|
|
||||||
var a = new FakeRealParticipant(viewerId: 1, FixtureCtx());
|
|
||||||
var b = new FakeParticipant(viewerId: ScriptedLifecycle.FakeOpponentViewerId, ScriptedBotContext());
|
|
||||||
var s = new BattleSession("bid-1", BattleType.Scripted, a, b, NullLogger<BattleSession>.Instance);
|
|
||||||
return (s, a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MatchContext FixtureCtx() => new(
|
private static MatchContext FixtureCtx() => new(
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
|
||||||
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
|
||||||
@@ -700,18 +594,30 @@ public class BattleSessionDispatchTests
|
|||||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
|
||||||
BattleType: 11);
|
BattleType: 11);
|
||||||
|
|
||||||
private static MatchContext ScriptedBotContext() => new(
|
|
||||||
SelfDeckCardIds: Array.Empty<long>(),
|
|
||||||
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
|
|
||||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
|
||||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
|
||||||
BattleType: 0);
|
|
||||||
|
|
||||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||||
Body: new RawBody(new Dictionary<string, object?>()));
|
Body: new RawBody(new Dictionary<string, object?>()));
|
||||||
|
|
||||||
|
private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) =>
|
||||||
|
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||||
|
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body));
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new()
|
||||||
|
{
|
||||||
|
["orderList"] = new List<object?>
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["move"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["idx"] = new List<object?> { (long)idx },
|
||||||
|
["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync
|
/// <summary>Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync
|
||||||
/// are no-ops; FrameEmitted exists but is never invoked by the test.</summary>
|
/// are no-ops; FrameEmitted exists but is never invoked by the test.</summary>
|
||||||
private sealed class FakeParticipant : IBattleParticipant
|
private sealed class FakeParticipant : IBattleParticipant
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Bridge;
|
||||||
|
using SVSim.BattleNode.Sessions;
|
||||||
|
using SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class BattleSessionStateTests
|
||||||
|
{
|
||||||
|
private sealed class StubParticipant : IBattleParticipant
|
||||||
|
{
|
||||||
|
public long ViewerId { get; }
|
||||||
|
public MatchContext Context { get; }
|
||||||
|
public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? FrameEmitted;
|
||||||
|
public StubParticipant(long id, MatchContext ctx) { ViewerId = id; Context = ctx; }
|
||||||
|
public Task PushAsync(SVSim.BattleNode.Protocol.MsgEnvelope e, bool n, CancellationToken c) => Task.CompletedTask;
|
||||||
|
public Task RunAsync(CancellationToken c) => Task.CompletedTask;
|
||||||
|
public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask;
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
private void Touch() => FrameEmitted?.Invoke(null!, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MatchContext Ctx(params long[] deck) => new(
|
||||||
|
SelfDeckCardIds: deck, ClassId: "1", CharaId: "1", CardMasterName: "cm",
|
||||||
|
CountryCode: "KOR", UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0",
|
||||||
|
FieldId: 0, IsOfficial: 0, BattleType: 11);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetOrSeedDeckMap_maps_idx_1based_to_deck_cardIds()
|
||||||
|
{
|
||||||
|
var state = new BattleSessionState();
|
||||||
|
var p = new StubParticipant(1, Ctx(900L, 901L, 902L));
|
||||||
|
|
||||||
|
var map = state.GetOrSeedDeckMap(p);
|
||||||
|
|
||||||
|
Assert.That(map[1], Is.EqualTo(900L));
|
||||||
|
Assert.That(map[2], Is.EqualTo(901L));
|
||||||
|
Assert.That(map[3], Is.EqualTo(902L));
|
||||||
|
Assert.That(map.ContainsKey(4), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetOrSeedDeckMap_is_idempotent_same_instance()
|
||||||
|
{
|
||||||
|
var state = new BattleSessionState();
|
||||||
|
var p = new StubParticipant(1, Ctx(900L));
|
||||||
|
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,8 @@ namespace SVSim.UnitTests.BattleNode.Sessions;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Md11 — confirms <see cref="BattleSession.RunAsync"/> drops the per-RealParticipant
|
/// Audit Md11 — confirms <see cref="BattleSession.RunAsync"/> drops the per-RealParticipant
|
||||||
/// <see cref="SVSim.BattleNode.Reliability.OutboundSequencer"/> archive when the session
|
/// <see cref="SVSim.BattleNode.Reliability.OutboundSequencer"/> archive when the session
|
||||||
/// terminates. The Scripted bot has no outbound archive of its own, so the test uses a
|
/// terminates. The NoOp bot has no outbound archive of its own, so the test uses a Bot
|
||||||
/// Scripted session (one Real, one ScriptedBot) and asserts only the Real side's archive
|
/// session (one Real, one NoOpBot) and asserts only the Real side's archive is cleared.
|
||||||
/// is cleared.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class BattleSessionTerminateCascadeTests
|
public class BattleSessionTerminateCascadeTests
|
||||||
@@ -25,7 +24,7 @@ public class BattleSessionTerminateCascadeTests
|
|||||||
var ws = new TestWebSocket();
|
var ws = new TestWebSocket();
|
||||||
var real = new RealParticipant(
|
var real = new RealParticipant(
|
||||||
ws, viewerId: 1, MakeFakeContext(), NullLogger<RealParticipant>.Instance);
|
ws, viewerId: 1, MakeFakeContext(), NullLogger<RealParticipant>.Instance);
|
||||||
var bot = new ScriptedBotParticipant();
|
var bot = new NoOpBotParticipant();
|
||||||
|
|
||||||
// Pre-load the archive so we can prove it was cleared (not just empty).
|
// Pre-load the archive so we can prove it was cleared (not just empty).
|
||||||
real.Outbound.AssignAndArchive(MakeEnvelope(NetworkBattleUri.Matched));
|
real.Outbound.AssignAndArchive(MakeEnvelope(NetworkBattleUri.Matched));
|
||||||
@@ -33,7 +32,7 @@ public class BattleSessionTerminateCascadeTests
|
|||||||
Assume.That(real.Outbound.Archive.Count, Is.EqualTo(2), "Precondition: archive populated.");
|
Assume.That(real.Outbound.Archive.Count, Is.EqualTo(2), "Precondition: archive populated.");
|
||||||
|
|
||||||
var session = new BattleSession(
|
var session = new BattleSession(
|
||||||
battleId: "test-bid", type: BattleType.Scripted,
|
battleId: "test-bid", type: BattleType.Bot,
|
||||||
a: real, b: bot, log: NullLogger<BattleSession>.Instance);
|
a: real, b: bot, log: NullLogger<BattleSession>.Instance);
|
||||||
|
|
||||||
// Drive RunAsync to completion: closing the incoming side causes
|
// Drive RunAsync to completion: closing the incoming side causes
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public class InMemoryBattleSessionStoreTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void RegisterThenGet_ReturnsRegisteredBattle()
|
public void RegisterThenGet_ReturnsRegisteredBattle()
|
||||||
{
|
{
|
||||||
var battle = new PendingBattle("bid-1", BattleType.Scripted, new BattlePlayer(906243102, FixtureCtx()), null);
|
var battle = new PendingBattle("bid-1", BattleType.Bot, new BattlePlayer(906243102, FixtureCtx()), null);
|
||||||
_store.RegisterPending(battle);
|
_store.RegisterPending(battle);
|
||||||
|
|
||||||
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle));
|
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle));
|
||||||
@@ -29,7 +29,7 @@ public class InMemoryBattleSessionStoreTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
|
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
|
||||||
{
|
{
|
||||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(1, FixtureCtx()), null));
|
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
||||||
Assert.That(_store.RemovePending("bid"), Is.True);
|
Assert.That(_store.RemovePending("bid"), Is.True);
|
||||||
Assert.That(_store.RemovePending("bid"), Is.False);
|
Assert.That(_store.RemovePending("bid"), Is.False);
|
||||||
}
|
}
|
||||||
@@ -37,8 +37,8 @@ public class InMemoryBattleSessionStoreTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Register_DuplicateBattleId_OverwritesPrior()
|
public void Register_DuplicateBattleId_OverwritesPrior()
|
||||||
{
|
{
|
||||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(1, FixtureCtx()), null));
|
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
|
||||||
_store.RegisterPending(new PendingBattle("bid", BattleType.Scripted, new BattlePlayer(2, FixtureCtx()), null));
|
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(2, FixtureCtx()), null));
|
||||||
Assert.That(_store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(2));
|
Assert.That(_store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
Normal file
114
SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using SVSim.BattleNode.Sessions.Dispatch;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class KnownListBuilderTests
|
||||||
|
{
|
||||||
|
// orderList as it arrives in a RawBody: a list of single-key op dicts.
|
||||||
|
private static List<object?> OrderListMove(int idx, int from, int to) => new()
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["move"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["idx"] = new List<object?> { (long)idx },
|
||||||
|
["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ExtractMoveTo_returns_to_for_matching_idx()
|
||||||
|
{
|
||||||
|
var to = KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 3);
|
||||||
|
Assert.That(to, Is.EqualTo(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ExtractMoveTo_returns_null_when_no_move_op_matches()
|
||||||
|
{
|
||||||
|
Assert.That(KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 99), Is.Null);
|
||||||
|
Assert.That(KnownListBuilder.ExtractMoveTo(null, playIdx: 3), Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ExtractMoveTo_returns_first_matching_move_op()
|
||||||
|
{
|
||||||
|
// A real PlayActions can carry several move ops; the played card's move comes first,
|
||||||
|
// later ops (token add/alter) target other idxs. Confirm first-match-wins, not last.
|
||||||
|
var orderList = new List<object?>
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["move"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["move"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["idx"] = new List<object?> { 31L, 32L }, ["isSelf"] = 1L, ["from"] = 0L, ["to"] = 40L,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 3), Is.EqualTo(30));
|
||||||
|
Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 31), Is.EqualTo(40));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildPlayedCard_returns_null_for_deck_card_with_no_matching_move_op()
|
||||||
|
{
|
||||||
|
// idx is in the deck, but the orderList has no move op for it → can't synthesize.
|
||||||
|
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||||
|
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(7, 10, 20));
|
||||||
|
Assert.That(entry, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildPlayedCard_synthesizes_entry_for_deck_card()
|
||||||
|
{
|
||||||
|
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||||
|
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20));
|
||||||
|
|
||||||
|
Assert.That(entry, Is.Not.Null);
|
||||||
|
Assert.That(entry!.Idx, Is.EqualTo(3));
|
||||||
|
Assert.That(entry.CardId, Is.EqualTo(128821011L));
|
||||||
|
Assert.That(entry.To, Is.EqualTo(20));
|
||||||
|
Assert.That(entry.Spellboost, Is.EqualTo(0));
|
||||||
|
Assert.That(entry.AttachTarget, Is.EqualTo(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BuildPlayedCard_returns_null_for_token_idx_not_in_deck()
|
||||||
|
{
|
||||||
|
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||||
|
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 31, orderList: OrderListMove(31, 10, 20));
|
||||||
|
Assert.That(entry, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void RenameTargets_passes_isSelf_through_verbatim()
|
||||||
|
{
|
||||||
|
var targetList = new List<object?>
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?> { ["targetIdx"] = 8L, ["isSelf"] = 0L },
|
||||||
|
};
|
||||||
|
var renamed = KnownListBuilder.RenameTargets(targetList);
|
||||||
|
|
||||||
|
Assert.That(renamed, Is.Not.Null);
|
||||||
|
Assert.That(renamed!.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(renamed[0].TargetIdx, Is.EqualTo(8));
|
||||||
|
Assert.That(renamed[0].IsSelf, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void RenameTargets_returns_null_for_missing_or_empty()
|
||||||
|
{
|
||||||
|
Assert.That(KnownListBuilder.RenameTargets(null), Is.Null);
|
||||||
|
Assert.That(KnownListBuilder.RenameTargets(new List<object?>()), Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,6 @@ public class NoOpBotParticipantTests
|
|||||||
public void ViewerId_is_FakeOpponent()
|
public void ViewerId_is_FakeOpponent()
|
||||||
{
|
{
|
||||||
var p = new NoOpBotParticipant();
|
var p = new NoOpBotParticipant();
|
||||||
Assert.That(p.ViewerId, Is.EqualTo(SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId));
|
Assert.That(p.ViewerId, Is.EqualTo(SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
using NUnit.Framework;
|
|
||||||
using SVSim.BattleNode.Bridge;
|
|
||||||
using SVSim.BattleNode.Protocol;
|
|
||||||
using SVSim.BattleNode.Protocol.Bodies;
|
|
||||||
using SVSim.BattleNode.Sessions;
|
|
||||||
using SVSim.BattleNode.Sessions.Participants;
|
|
||||||
|
|
||||||
namespace SVSim.UnitTests.BattleNode.Sessions.Participants;
|
|
||||||
|
|
||||||
[TestFixture]
|
|
||||||
public class ScriptedBotParticipantTests
|
|
||||||
{
|
|
||||||
[Test]
|
|
||||||
public async Task PushAsync_TurnEnd_fires_three_FrameEmitted_in_order()
|
|
||||||
{
|
|
||||||
var p = new ScriptedBotParticipant();
|
|
||||||
var emitted = new List<NetworkBattleUri>();
|
|
||||||
p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; };
|
|
||||||
|
|
||||||
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEnd), noStock: false, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.That(emitted, Is.EqualTo(new[]
|
|
||||||
{
|
|
||||||
NetworkBattleUri.TurnStart,
|
|
||||||
NetworkBattleUri.TurnEnd,
|
|
||||||
NetworkBattleUri.Judge,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task PushAsync_TurnEndFinal_does_NOT_fire_burst()
|
|
||||||
{
|
|
||||||
// TurnEndFinal is the game-end signal — owned by BattleSession's TurnEndFinal
|
|
||||||
// dispatch arm, which pushes BattleFinish per-side. The bot no longer reacts to
|
|
||||||
// it; reacting would race the BattleFinish with the no-longer-needed 3-frame
|
|
||||||
// burst. Only regular TurnEnd triggers the burst.
|
|
||||||
var p = new ScriptedBotParticipant();
|
|
||||||
var fired = 0;
|
|
||||||
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
|
||||||
|
|
||||||
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.That(fired, Is.EqualTo(0),
|
|
||||||
"TurnEndFinal must not trigger the bot's burst — the dispatch arm pushes BattleFinish directly.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task PushAsync_other_uris_do_not_fire()
|
|
||||||
{
|
|
||||||
var p = new ScriptedBotParticipant();
|
|
||||||
var fired = 0;
|
|
||||||
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
|
||||||
|
|
||||||
foreach (var uri in new[]
|
|
||||||
{
|
|
||||||
NetworkBattleUri.Matched, NetworkBattleUri.BattleStart, NetworkBattleUri.Deal,
|
|
||||||
NetworkBattleUri.Swap, NetworkBattleUri.Ready, NetworkBattleUri.PlayActions,
|
|
||||||
NetworkBattleUri.TurnStart, NetworkBattleUri.TurnEndActions, NetworkBattleUri.Echo,
|
|
||||||
NetworkBattleUri.Judge, NetworkBattleUri.BattleFinish,
|
|
||||||
})
|
|
||||||
{
|
|
||||||
await p.PushAsync(NewEnvelope(uri), noStock: false, CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.That(fired, Is.EqualTo(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task RunAsync_returns_immediately()
|
|
||||||
{
|
|
||||||
var p = new ScriptedBotParticipant();
|
|
||||||
await p.RunAsync(CancellationToken.None);
|
|
||||||
Assert.Pass();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
|
|
||||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
|
||||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
|
||||||
Body: new ResultCodeOnlyBody());
|
|
||||||
}
|
|
||||||
@@ -12,18 +12,24 @@ namespace SVSim.UnitTests.Controllers;
|
|||||||
public class ArenaTwoPickBattleControllerTests
|
public class ArenaTwoPickBattleControllerTests
|
||||||
{
|
{
|
||||||
[Test]
|
[Test]
|
||||||
public async Task DoMatching_AuthenticatedViewer_Returns3004WithBattleIdAndNodeUrl()
|
public async Task DoMatching_joiner_Returns3004WithBattleIdAndNodeUrlAndCardMaster()
|
||||||
{
|
{
|
||||||
using var factory = new SVSimTestFactory();
|
using var factory = new SVSimTestFactory();
|
||||||
var viewerId = await factory.SeedViewerAsync();
|
var vidA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_021UL);
|
||||||
await SeedCompleteTwoPickRunAsync(factory, viewerId);
|
var vidB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_022UL);
|
||||||
|
await SeedCompleteTwoPickRunAsync(factory, vidA);
|
||||||
|
await SeedCompleteTwoPickRunAsync(factory, vidB);
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(vidA);
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(vidB);
|
||||||
|
|
||||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
|
||||||
var req = new {
|
var req = new {
|
||||||
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
||||||
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
||||||
};
|
};
|
||||||
var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req));
|
|
||||||
|
// A parks first; B triggers the pair and gets the 3004 joiner response.
|
||||||
|
await clientA.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
|
||||||
|
var resp = await clientB.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
|
||||||
|
|
||||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
@@ -72,29 +78,6 @@ public class ArenaTwoPickBattleControllerTests
|
|||||||
Assert.That(root.GetProperty("node_server_url").GetString(), Is.EqualTo(""));
|
Assert.That(root.GetProperty("node_server_url").GetString(), Is.EqualTo(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task DoMatching_with_scripted_flag_returns_3004_Scripted_match_immediately()
|
|
||||||
{
|
|
||||||
using var factory = new SVSimTestFactory();
|
|
||||||
var vid = await factory.SeedViewerAsync();
|
|
||||||
await SeedCompleteTwoPickRunAsync(factory, vid);
|
|
||||||
using var client = factory.CreateAuthenticatedClient(vid);
|
|
||||||
|
|
||||||
var req = new {
|
|
||||||
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
|
||||||
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
|
||||||
};
|
|
||||||
var resp = await client.PostAsync("/arena_two_pick_battle/do_matching?scripted=1", JsonContent.Create(req));
|
|
||||||
|
|
||||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004));
|
|
||||||
Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task DoMatching_two_pollers_get_3004_joiner_and_3007_owner_with_same_BattleId()
|
public async Task DoMatching_two_pollers_get_3004_joiner_and_3007_owner_with_same_BattleId()
|
||||||
{
|
{
|
||||||
@@ -137,35 +120,6 @@ public class ArenaTwoPickBattleControllerTests
|
|||||||
"Owner and joiner must see the same node_server_url.");
|
"Owner and joiner must see the same node_server_url.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task DoMatching_SoloDefaultsToScripted_flag_makes_solo_poll_return_3004_without_query_param()
|
|
||||||
{
|
|
||||||
using var factory = new SVSimTestFactory();
|
|
||||||
// BattleNodeOptions is a singleton in DI; flipping it before the request takes
|
|
||||||
// effect immediately for this factory. Real deployments toggle it via the
|
|
||||||
// "BattleNode:SoloDefaultsToScripted" key in appsettings*.json.
|
|
||||||
factory.Services.GetRequiredService<BattleNodeOptions>().SoloDefaultsToScripted = true;
|
|
||||||
|
|
||||||
var vid = await factory.SeedViewerAsync();
|
|
||||||
await SeedCompleteTwoPickRunAsync(factory, vid);
|
|
||||||
using var client = factory.CreateAuthenticatedClient(vid);
|
|
||||||
|
|
||||||
var req = new {
|
|
||||||
deck_no = 1L, need_init = 1, log = 1, excluded_field_id_list = new long[] { }, use_stage_select = 1, is_default_skin = 0,
|
|
||||||
viewer_id = "0", steam_id = 0, steam_session_ticket = "",
|
|
||||||
};
|
|
||||||
// No ?scripted=1 — the flag alone should drive the Scripted branch.
|
|
||||||
var resp = await client.PostAsync("/arena_two_pick_battle/do_matching", JsonContent.Create(req));
|
|
||||||
|
|
||||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
|
||||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
Assert.That(root.GetProperty("matching_state").GetInt32(), Is.EqualTo(3004),
|
|
||||||
"SoloDefaultsToScripted=true should bypass pair-up and return a Scripted 3004 SUCCEEDED.");
|
|
||||||
Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty);
|
|
||||||
Assert.That(root.GetProperty("node_server_url").GetString(), Does.Contain("/socket.io/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task DoMatching_NoActiveRun_Returns400WithErrorCode()
|
public async Task DoMatching_NoActiveRun_Returns400WithErrorCode()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class BattlePassControllerBuyTests
|
|||||||
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||||
{
|
{
|
||||||
Id = MakeRewardId(23, BattlePassTrack.Premium, 2),
|
Id = MakeRewardId(23, BattlePassTrack.Premium, 2),
|
||||||
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
|
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = (UserGoodsType)9,
|
||||||
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
||||||
});
|
});
|
||||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ public class BattlePassControllerInfoTests
|
|||||||
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||||
{
|
{
|
||||||
Id = 23 * 10_000L + 0 * 1_000 + 2, // MakeId(23, Normal=0, 2)
|
Id = 23 * 10_000L + 0 * 1_000 + 2, // MakeId(23, Normal=0, 2)
|
||||||
SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = 9,
|
SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = (UserGoodsType)9,
|
||||||
RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false,
|
RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false,
|
||||||
});
|
});
|
||||||
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||||
{
|
{
|
||||||
Id = 23 * 10_000L + 1 * 1_000 + 2, // MakeId(23, Premium=1, 2)
|
Id = 23 * 10_000L + 1 * 1_000 + 2, // MakeId(23, Premium=1, 2)
|
||||||
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
|
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = (UserGoodsType)9,
|
||||||
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ public class BuildDeckControllerBuyTests
|
|||||||
{
|
{
|
||||||
new BuildDeckProductRewardEntry
|
new BuildDeckProductRewardEntry
|
||||||
{
|
{
|
||||||
RewardIndex = 1, RewardType = 6 /* Sleeve */,
|
RewardIndex = 1, RewardType = (UserGoodsType)6 /* Sleeve */,
|
||||||
RewardDetailId = 3000021, RewardNumber = 1, MessageId = 51004,
|
RewardDetailId = 3000021, RewardNumber = 1, MessageId = 51004,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -383,13 +383,13 @@ public class BuildDeckControllerBuyTests
|
|||||||
// Tier 1: one card reward, unlocked on the 1st series purchase.
|
// Tier 1: one card reward, unlocked on the 1st series purchase.
|
||||||
new BuildDeckSeriesRewardEntry
|
new BuildDeckSeriesRewardEntry
|
||||||
{
|
{
|
||||||
TierIndex = 1, ItemIndex = 0, RewardType = 5,
|
TierIndex = 1, ItemIndex = 0, RewardType = (UserGoodsType)5,
|
||||||
RewardDetailId = 10001001L, RewardNumber = 1, MessageId = 51004,
|
RewardDetailId = 10001001L, RewardNumber = 1, MessageId = 51004,
|
||||||
},
|
},
|
||||||
// Tier 2: one card reward, unlocked on the 2nd series purchase.
|
// Tier 2: one card reward, unlocked on the 2nd series purchase.
|
||||||
new BuildDeckSeriesRewardEntry
|
new BuildDeckSeriesRewardEntry
|
||||||
{
|
{
|
||||||
TierIndex = 2, ItemIndex = 0, RewardType = 5,
|
TierIndex = 2, ItemIndex = 0, RewardType = (UserGoodsType)5,
|
||||||
RewardDetailId = 10001002L, RewardNumber = 1, MessageId = 51004,
|
RewardDetailId = 10001002L, RewardNumber = 1, MessageId = 51004,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user